Backend

Publishing one post to four social networks: claims, retries and partial failure

Social Poster writes a post once and publishes it to Facebook, Instagram, LinkedIn and X at a scheduled time. The hard part is not the APIs. It is what happens when two networks succeed and one fails. Here is the design that handles it.

Social Poster is a small scheduling tool. You write a post once, adjust the caption per network if you want, attach media, pick a time, and it publishes automatically to a Facebook page, Instagram, LinkedIn (a personal profile and a company page) and X.

Each platform API has its quirks. Instagram needs media to be reachable at a public URL and processes it asynchronously. X rotates its OAuth refresh token on every use. LinkedIn has separate flows for people and organisations. We wrote one publisher per platform behind a common interface, and those publishers were the expected work.

The design questions that mattered more were these: what does "published" mean for a post that goes to four places, how do you avoid publishing twice, and where do platform passwords and tokens live? This post covers our answers, in a Spring Boot backend with MySQL.

A post is not the unit of work

The first and most important decision: a post is never "published" or "failed" as a whole. Each post has one target per network, and each target has its own status, attempt count, next attempt time, error message and external post ID.

The post's status is derived from its targets, by one small function:

public static PostStatus derive(List<PostTarget> targets) {
    boolean anyPublishing = targets.stream().anyMatch(t -> t.getStatus() == TargetStatus.PUBLISHING);
    if (anyPublishing) return PostStatus.PUBLISHING;

    boolean anyPending = targets.stream().anyMatch(t -> t.getStatus() == TargetStatus.PENDING);
    if (anyPending) return PostStatus.SCHEDULED;

    boolean anyPublished = targets.stream().anyMatch(t -> t.getStatus() == TargetStatus.PUBLISHED);
    boolean anyFailed = targets.stream().anyMatch(t -> t.getStatus() == TargetStatus.FAILED);
    if (anyPublished && anyFailed) return PostStatus.PARTIAL;
    if (anyFailed) return PostStatus.FAILED;
    return PostStatus.PUBLISHED;
}

PARTIAL is the state most tools hide. If a post went out on three networks and failed on the fourth, the honest status is "partially published", with the failed target clearly marked. Showing "failed" would invite the user to retry everything and create duplicates on the three networks that worked. Showing "published" would hide the failure.

Because the status is always recomputed from the targets, it can never drift out of sync with them.

Claim before you publish

A scheduler runs once a minute by default and looks for targets that are due. The risk with any scheduler is doing the same work twice: two overlapping runs, or two instances during a deploy, both pick up the same target and publish the same post twice. Unlike a database write, a social media post cannot be rolled back quietly.

So before publishing, a run must claim the target with a conditional update:

@Modifying
@Query("""
    update PostTarget t
       set t.status = TargetStatus.PUBLISHING, t.claimedAt = :now
     where t.id = :id and t.status = TargetStatus.PENDING
    """)
int claim(@Param("id") Long id, @Param("now") Instant now);

The update only succeeds if the target is still PENDING. The database makes that check and the change atomic, so exactly one caller gets 1 back. Everyone else gets 0 and moves on:

Integer claimed = txTemplate.execute(status -> targetRepository.claim(targetId, now));
if (claimed == null || claimed != 1) {
    continue; // no longer PENDING: another worker took it
}

Short transactions around a slow call

A platform API call can take seconds. Holding a database transaction open across it would block rows and connections for no reason. Each tick is therefore split into separate, short transactions:

  1. Claim the target (its own transaction, committed immediately).
  2. Prepare the data: caption (the per-network override or the default), media, platform.
  3. Call the publisher with no transaction open.
  4. Record the outcome in a new transaction.

The transaction template is set to REQUIRES_NEW, so each step commits on its own regardless of what surrounds it.

What if a run dies halfway?

Claiming creates a new failure mode. If the process is killed after the claim but before the result is recorded (a deploy, a crash, an out-of-memory kill), the target stays in PUBLISHING forever.

Every tick starts by releasing claims that are too old:

Instant staleCutoff = now.minus(STALE_CLAIM_MINUTES, ChronoUnit.MINUTES); // 15
int reset = targetRepository.resetStaleClaims(staleCutoff);

A claim older than 15 minutes goes back to PENDING and will be retried. That number is a trade-off. Too short, and a slow but live publish could be picked up a second time. Too long, and a crashed post waits a long time. No platform call should take anywhere near 15 minutes.

Not every failure deserves a retry

Publishers return a typed result, not an exception. A failure carries a kind:

  • TRANSIENT: network errors, timeouts, server errors on the platform side.
  • RATE_LIMIT: the platform asked us to slow down.
  • PERMANENT: an expired token, a missing permission, media the platform rejects.

Only the first two are retried automatically:

boolean retryable = failure.kind() == ErrorKind.TRANSIENT || failure.kind() == ErrorKind.RATE_LIMIT;
if (retryable && target.getAttemptCount() < MAX_ATTEMPTS) {          // 3
    target.setStatus(TargetStatus.PENDING);
    target.setNextAttemptAt(now.plus(BACKOFF_MINUTES, ChronoUnit.MINUTES)); // 5
    target.setErrorMessage(failure.message());
} else {
    target.setStatus(TargetStatus.FAILED);
    target.setErrorMessage(failure.message());
}

Retrying a permanent error just repeats it, and with some platforms, repeated bad requests count against your app. A permanent failure goes straight to FAILED with the platform's message, which usually tells the user exactly what to fix ("reconnect your account").

Manual retry, for one target only

After the user fixes the cause, they can retry the failed target, and only that target:

if (target.getStatus() != TargetStatus.FAILED) {
    throw new IllegalStateException("Only FAILED targets can be retried");
}
target.setStatus(TargetStatus.PENDING);
target.setAttemptCount(0);
target.setNextAttemptAt(null);
target.setErrorMessage(null);

The networks that already succeeded are untouched, so a retry can never create a duplicate there.

Encrypting platform credentials

The tool stores access tokens, refresh tokens and app secrets for four platforms. Those are the keys to a company's social accounts, so they are never stored in plain text. Each value is encrypted with AES-256 in GCM mode before it reaches the database:

private static final int IV_LEN = 12, TAG_BITS = 128;

public String encrypt(String plaintext) {
    byte[] iv = new byte[IV_LEN];
    random.nextBytes(iv);                                   // fresh random IV every time
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
    byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
    return Base64.getEncoder().encodeToString(
        ByteBuffer.allocate(iv.length + ct.length).put(iv).put(ct).array());
}
  • GCM is authenticated encryption. If a stored value is altered, decryption fails loudly instead of returning garbage.
  • A new random 12-byte IV per value. Reusing an IV with the same key in GCM is catastrophic, so it is generated every time and stored in front of the ciphertext.
  • The key is not in the database. It comes from the environment, and the service refuses to start if it is missing or not exactly 32 bytes. A misconfigured server fails at boot, not at the first publish.

Media follows the same "least exposure" idea. Instagram needs a public URL to fetch an image, so media is served through tokenised links, and those tokens are cleared as soon as the post reaches a final state.

Summary

  • Track status per destination, derive the overall status, and show PARTIAL honestly.
  • Claim work with a conditional update before doing anything you cannot undo.
  • Keep transactions short and never hold one across a network call.
  • Recover stale claims automatically.
  • Classify failures and retry only the ones that can succeed.
  • Encrypt secrets with authenticated encryption, a fresh IV and a key that lives outside the database.