Skip to content

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?

Code for this question
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);
Pick one

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

Classify the call. A read has no business effect. A write needs a server contract before the client repeats it.

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

a field worker places pale forms in a portable grey case while the delivery road is blocked. Curved blue road edges lead toward green-and-blue freight containers. It suggests locally prepared work awaiting a later handoff.
An illustration to help it stick. The mechanism is in the sections above.

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?

Code for this question
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' });}
Pick one

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?

Pick one

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?

Pick one

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?

Pick one

Question 5 of 5

Two Retry clicks begin before either asynchronous read completes.

Which handler can issue two sends while retaining one durable entry?

Pick one

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

Checked against the tools and docs as of 2026-09-30.