Skip to content
Backend Infrastructure8 min

202 Accepted is not “saved”: the write your UI lied about

The endpoint queues the write and returns 202. The app shows the value the user typed. If the queue never drains, the user believes something is true that the database never recorded — and acts on it.

Failure modeA mutation is accepted onto a queue and acknowledged with 202, while the client optimistically renders the submitted value instead of the stored one. A dropped or delayed job leaves the user's view and the database permanently disagreeing.

Commercial risk

For a fire-and-forget preference, an occasionally-lost write is invisible. For identity data — a phone number, a bank detail, a delivery address — the user proceeds on a value the system never stored, and the failure surfaces at the worst moment: verification, payment, delivery.

The specific incident is a phone number a user “changed,” saw reflected on screen, and relied on for SMS verification — while the queued write was still pending or lost. The verification failed against the old number, the user was locked out or flagged, and support could find no error anywhere because nothing errored.

Returning `202 Accepted` from a write endpoint is a legitimate, often correct, architectural choice: it moves slow persistence off the request path and keeps the API responsive under load. The failure mode is not the 202 — it is what the client does with it. If the app renders the value the user submitted, rather than a value it read back from the system of record, it is asserting success it has not confirmed.

This is the async cousin of every optimistic-UI bug, and it is uniquely dangerous for data the user will later depend on being exactly right. Here is where the gap opens and how to keep the user's mental model tied to reality.

1. The optimistic render that outruns the write

The pattern is seductive because it makes the UI feel instant. The endpoint enqueues a job and returns 202; the client, knowing a re-read might still return the old value (the write hasn't landed yet), shows what the user typed instead. Under normal conditions the worker drains the queue a second or two later and the two converge. The user never knows there was a gap.

But the queue is a system that can fail independently of the request. The worker can crash, the job can be malformed and dead-letter, a network partition can sit between the API and the queue or the queue and the database. In every one of those cases the client has already shown success. There is no error to catch because from the API's perspective nothing went wrong — the write was accepted. It just was never applied.

// The endpoint: accepted, not applied.
// PATCH /users/phone-number  ->  202, body: none

export async function updatePhoneNumber(next: string): Promise<void> {
  await apiPatch("/users/phone-number", { phone_number: next });
  // Tempting, and wrong for identity data:
  // showing the submitted value asserts a success we never confirmed.
  store.phoneNumber = next; // optimistic — may never be true
}

2. Compounding: two edits that race in the queue

It gets worse when the user edits twice quickly. Each edit enqueues its own job. If the jobs run out of order — which queues under load do not guarantee against, especially across retries — the earlier value can be written last and win. Now the database holds a value the user changed away from, while the UI shows the latest thing they typed. The two are not just briefly inconsistent; they are durably wrong in opposite directions.

The user, seeing the correct value on screen, has no reason to re-enter it. The system, holding the stale value, has no reason to flag it. The divergence is stable, silent, and discovered only downstream — when the wrong number receives the SMS, or the wrong address receives the parcel.

3. Make the client depend on the read, not the submit

The fix is to close the loop. For data that matters, the write is not complete when the API says 202 — it is complete when the client has read the new value back from the system of record. Model the pending state explicitly: show the field as "updating," poll or subscribe until the read confirms the new value, and only then render it as settled. If confirmation doesn't arrive within a bound, surface that honestly rather than pretending success.

For values that must be right before the user acts on them — anything feeding verification, payment, or fulfilment — go further and make the critical path synchronous. It is entirely reasonable to queue a display-name change and to write a phone number in-request. The decision is per-field, driven by the cost of being wrong. Match the durability guarantee to the consequence, and give the queue an idempotency key and a dead-letter alarm so a stuck job is a page, not a mystery.

// Confirm against the system of record before calling it done.
export async function updatePhoneNumber(next: string): Promise<Result> {
  await apiPatch("/users/phone-number", { phone_number: next });

  // Poll the read model until it reflects the write, with a bound.
  for (let attempt = 0; attempt < 5; attempt++) {
    await delay(backoff(attempt));
    const current = await apiGet("/users/me");
    if (current.phone_number === next) {
      store.phoneNumber = next;         // confirmed by the source of truth
      return { status: "confirmed" };
    }
  }
  // Never lie about success — the write may be stuck in the queue.
  return { status: "pending", message: "Still saving — we'll confirm shortly." };
}

A 202 is a promise to try, not a receipt for a completed write. When the client renders the submitted value as if the promise were already kept, it manufactures a truth the database never agreed to — and for identity, payment, or fulfilment data, that manufactured truth becomes a real-world failure the moment the user relies on it.

Tie the UI to the read, not the submit. Show pending honestly, confirm against the system of record, and make the fields whose wrongness is expensive write synchronously. The queue is a fine place for work; it is a dangerous place to hide a promise you told the user you'd already kept.

Need this level of structural integrity engineered for your system?

We audit and re-architect systems where a single one of these failure modes costs real money. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint