Durable client retries and cancellation
Keep one identifiable command until its outcome can be reconciled.
Predict first
Before you read: predict
The endpoint stores one result per operation id and body. outbox wraps an IndexedDB store keyed by id. The save below gets an HTTP 503 after the server has recorded command-7; the tab then reloads and the resend runs. A trace gives the stored row before the reload, the rows after the resend, what the resend did, and the server's effects.
Which trace follows?
const command = { id: 'delivery-7', op: 'command-7', body: 'note-v1' };await outbox.put({ ...command, status: 'sending' });const reply = await fetch('/save', { method: 'POST', headers: { 'x-operation-id': command.op, 'x-command-body': command.body } });if (!reply.ok) await outbox.put({ ...command, status: 'retry' });// After the reload:const row = await outbox.get('delivery-7');if (!row) return;const again = await fetch('/save', { method: 'POST', headers: { 'x-operation-id': row.op, 'x-command-body': row.body } });if (again.ok) await outbox.delete(row.id);The 30-second version
A retry needs two answers: is the operation safe to repeat, and can the browser still identify that operation after interruption? Give the command a stable identity, store it before dispatch, show its local state, and reconcile it with the endpoint’s result. Backoff controls retry timing elsewhere. An abort ends the view’s wait; it does not settle a business command.
Keep one identifiable command until its outcome can be reconciled.
How it works
Step 1 of 9
Analogy: A field worker’s forms case at a blocked delivery road
| At the delivery point | In the client |
|---|---|
| completed forms go into the portable case before the road is attempted | persist a command before the request |
| a form’s reference number stays on the paper | stable operation id |
| a closed road does not turn a form into a completed delivery | 503 does not acknowledge a command |
| the case keeps the original form while a newer revision stays at the desk | immutable sent command plus separate draft |
| a departing worker stops watching the road | abort the view’s request |
In real terms: the portable case is local browser storage, not a server ledger. Limit: the artwork cannot show endpoint key scope, acknowledgement, a conflict response, or browser abort semantics.
Examples, run for real
4.1 ExamHall moderation-note case
A moderator writes a note form into IndexedDB before the request begins. After a reload, the browser reads the stored operation id and the locally recorded state. The example only demonstrates that the record survives a browser lifetime; it does not declare a retry outcome or an endpoint contract.
4.2 Checkout editor and departing view
A payment-options read belongs to the visible editor. Cleanup aborts that read so its completion cannot update the departed screen. A separate delivery command, if one exists, remains a record to reconcile on a future screen. This example does not choose a command state or send a retry.
4.3 What this lesson does not set
ds-retries-backoff-jitter owns delay curves, retry budgets and jitter. api-idempotency-keys owns the endpoint’s atomic claim and replay behavior. This lesson applies those supplied contracts to local rows; local persistence cannot make an unsafe endpoint repeat safe.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
The outbox holds shipping and voucher, both sending. The handler below sends them together. The shipping endpoint answers 200 and the voucher endpoint answers 503.
After the handler runs and the tab reloads, which rows does the outbox hold?
const rows = await outbox.all();try { await Promise.all(rows.map((row) => fetch(`/outbox/${row.id}`, { method: 'POST', headers: { 'x-operation-id': row.op } }))); for (const row of rows) await outbox.delete(row.id);} catch { for (const row of rows) await outbox.put({ ...row, status: 'retry' });}Question 2 of 5
Before reload, four calls each receive 503: a catalog GET, a reservation POST whose key is retained by the endpoint, a delivery POST whose key is retained only for the current tab, and an unkeyed coupon POST.
After reload, which pair may the client resend while completing both calls without creating another effect?
Question 3 of 5
A stored delivery command reaches the endpoint before route cleanup aborts the browser fetch. The endpoint returns its stored result for the same operation id and body without another effect; a changed body under that id returns 422.
Which cleanup action should the client take before the next mount?
Question 4 of 5
Reload restores old = { op: 'delivery-7', body: 'old' } from the outbox and draft = { body: 'new' } from the editor. The endpoint returns its stored 200 result for the same operation id and body, and returns 422 when that id carries a different body.
Which handler should reconcile the pending delivery before a later save?
Question 5 of 5
Two Retry clicks begin before either asynchronous read completes.
Which handler can issue two sends while retaining one durable entry?
Go deeper
Pro tip
Record an operation id beside local state changes during debugging so a browser row can be compared with request evidence without exposing its payload.
Source
- AWS Builders’ Library, Timeouts, retries, and backoff with jitter: backoff policy ownership.
- MDN, IndexedDB API: browser-side transactional storage.
- IETF HTTPAPI, Idempotency-Key draft-07: idempotency-key contract context.
- MDN, AbortSignal: abortable operations.
- MDN, Window: fetch(), read 2026-09-30: “A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.).”
Related lessons
Checked against the tools and docs as of 2026-09-30.