OfflineGENERALBROWSER-SPECIFICPLATFORM-SPECIFIC

The Offline Mutation Queue

Change offline, persist locally, reconnect, sync, resolve conflicts — and the last step is a product decision, not a technical default.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

A user changed something with no network. Where does that change live, in what order does it reach the server, and who decides what happens when the server disagrees?

The user intent

Someone marks four tasks done on the underground. They expect all four to be done when they surface — and if a colleague changed one of them in the meantime, they expect to find out rather than to silently win or silently lose.

The obvious build

Keep failed requests in an array, retry them when online fires, and replay them in order. It is a queue; queues are simple.

Why it breaks

The array is in memory. The user closes the tab, the phone kills the background tab, or the service worker is terminated between events — and the queue is gone with no error and no trace (IndexedDB exists for exactly this).

How it breaks in a real browser
  • The array is in memory. The user closes the tab, the phone kills the background tab, or the service worker is terminated between events — and the queue is gone with no error and no trace (IndexedDB exists for exactly this).
  • A retry sends the same "create order" twice because the first attempt actually reached the server and the response was lost on the way back. The user has two orders and no idea why (Idempotency Keys: The Mechanism in API Design).
  • Replaying in order is not the same as replaying correctly: rename A -> B followed by delete A is fine, and reversed it is a 404 followed by an orphan.
  • Six of eight queued mutations succeed and two fail. The array has been drained, the UI shows everything as saved, and the two failures are unrecoverable.
  • The server accepts the change but it was based on a version from three hours ago, and it silently overwrites an edit somebody else made in between. Nobody is told, and the lost work is discovered days later.
  • Two tabs of the same app both flush the same queue at the same time, so every mutation is sent twice from the same device.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A mutation queue is a durable log of intents, not a list of pending HTTP requests. Each entry says what the user meant — "set task 812 done" — plus enough context to send it, retry it and reconcile it.
  • It must live in storage that survives tab close, worker termination and process death. In a browser that means IndexedDB: asynchronous, transactional and large enough for structured records. localStorage is synchronous, small and string-only; memory is not storage (Choosing Browser Storage).
  • Each entry needs a client-generated identifier created at the moment the user acts, sent with the request and used by the server to deduplicate. That identifier is what makes a retry safe (Idempotency in API Design).
  • Each entry needs a base version: the ETag, updatedAt or revision the user was looking at when they made the change. Without it the server cannot detect a conflict, only overwrite (Conditional Requests: ETags, 304 and 412 in API Design).
  • Ordering is a per-entity property, not a global one. Mutations to the same entity must be applied in the order the user made them; mutations to unrelated entities need not be serialised and serialising them makes one failure block everything.
  • The flush is a loop over independent items with independent outcomes: succeeded, retryable failure, permanent failure, conflict. An entry leaves the queue only on success or on a resolved permanent outcome — never because the loop finished (Partial Failure: When 3 of 5 Succeed in API Design).
  • Conflict resolution happens when the server says the base version no longer matches. Last-write-wins, field-level merge, or asking the user are three different products, and the mechanism cannot pick between them.
  • The Background Sync API can ask the browser to wake a service worker when connectivity returns, so a queue can drain without the tab being open — where it is implemented. It is an enhancement, never the only path.

What this makes the browser do

And which of it is avoidable.

  • IndexedDB transactions on the main thread are asynchronous but their callbacks are not free; a flush of hundreds of entries interleaves storage work with whatever else the thread is doing (What the Main Thread Owns).
  • Each queued mutation, once flushed, is a real request — so a long queue meeting a reconnect is a burst the browser must schedule and the server must absorb.
  • Re-rendering per queue item as it drains can produce a long run of small layout and paint passes. Batch the UI update even when the queue drains one at a time.
  • Storage quota applies: a queue holding large payloads (an image, a long document body) competes with your caches for the same origin budget.
  • Background Sync, where available, means the browser may start your worker with no page open at all — a context where nothing about the DOM exists and every assumption about page state is wrong.

Change, persist, reconnect, sync, resolve

Five steps, and the interesting ones are the first and the last. The first because the temptation is to show success before the intent is durable; the last because it is the only step where the correct behaviour cannot be derived from engineering principles at all.

The middle three are mechanics with known answers: durable storage, idempotency, per-entity ordering, per-item outcomes. Get those right and the queue is boring, which is what you want from a queue.

Where an offline change lives
1. persist first2. then show3. reachable4. per-entity orderversion mismatch5. product decisionUser editsIndexedDB: intent + key + base versionSingle flusher (Web Lock / worker)ServerApplied — delete entryBase version staleOverwrite / merge / ask the userLocal state: shown as pending
UserLLMAgentToolDataDecisionHumanGuardrail
The life of one offline change
  1. 1
    Change

    The user acts. Generate an idempotency key and capture the base version of the entity they were looking at.

    fails by Capturing neither, which makes every later step guesswork: retries duplicate and conflicts are undetectable.

  2. 2
    Persist

    Write the intent to IndexedDB in a transaction. Only then update local state and tell the user it is saved on this device.

    fails by Keeping it in memory, or telling the user first — a tab close between the two loses work the UI already confirmed.

  3. 3
    Reconnect

    Detect that your origin is actually reachable — a successful request, not an online event — and elect a single flusher across tabs.

    fails by Flushing on the event, so the first attempts fail; or flushing from every tab, so everything is sent several times.

  4. 4
    Sync

    Send entries in per-entity order with the key and the base version. Record each outcome independently and delete each entry in the transaction that records its success.

    fails by Treating the batch as one unit, so a failure at item six loses or repeats the first five (Partial Failure: When 3 of 5 Succeed in API Design).

  5. 5
    Resolve

    For a rejected write, apply the policy this field has: overwrite, merge, or ask. Show the user what happened either way.

    fails by Having no policy, which means last-write-wins by accident, applied to every field including the ones where it destroys work.

Nothing here is browser-specific. It is the client-side shape of a problem that Distributed Systems states in general terms and that the user experiences as "did my edit survive?".

A queue entry that can survive anything

The record below is the whole design in one shape. Every field is there because of a specific failure: the key because responses get lost, the base version because other people edit things, the attempt count because some mutations will never succeed, and entity because ordering is per entity and not global.

Note the transaction boundaries in the flush. An entry is deleted in the same transaction that observed its success, and a failure updates the entry rather than dropping it. A process killed at any point between two transactions repeats at most one attempt — which is safe precisely because the key makes it safe.

The intent, and a flush that tolerates being killed
1type Pending = {
2 id: string // idempotency key, generated when the user acted
3 entity: string // ordering domain: 'task:812'. NOT a global sequence.
4 seq: number // order within that entity
5 op: 'update' | 'create' | 'delete'
6 payload: unknown
7 baseVersion: string | null // the ETag/revision the user was looking at
8 createdAt: number
9 attempts: number
10 state: 'pending' | 'failed' | 'conflict'
11}
12
13// 1. Persist BEFORE showing success. This ordering is the lesson.
14async function enqueue(entry: Pending) {
15 await db.put('queue', entry) // durable
16 applyLocally(entry) // then optimistic UI
17 void flush() // then, maybe, the network
18}
19
20async function flushEntity(entity: string) {
21 const entries = (await db.byEntity('queue', entity)).sort((a, b) => a.seq - b.seq)
22
23 for (const entry of entries) {
24 const res = await send(entry) // sets Idempotency-Key and If-Match
25
26 if (res.ok) {
27 await db.delete('queue', entry.id) // outcome + removal, one txn
28 continue
29 }
30 if (res.status === 409 || res.status === 412) {
31 await db.put('queue', { ...entry, state: 'conflict' })
32 return // stop THIS entity; others keep going
33 }
34 if (res.retryable && entry.attempts < MAX_ATTEMPTS) {
35 await db.put('queue', { ...entry, attempts: entry.attempts + 1 })
36 return // backoff with jitter, retry later
37 }
38 await db.put('queue', { ...entry, state: 'failed' }) // needs a human
39 return
40 }
41}

Two things are load-bearing and easy to miss. return rather than continue on failure preserves order within the entity. And the flush is per entity, so a poison entry on task:812 never blocks task:900.

Conflict resolution is a product decision

GENERALThe options are the same whichever backend you run; what differs is which ones your API can express — a server with no conditional-write support can only offer last-write-wins, so the decision is constrained by the contract before the UI ever sees it (How API Shape Drives UI Complexity).

When the server says the base version no longer matches, there is no technically correct answer. Overwriting, merging and asking are all defensible, they produce different outcomes for real people, and the difference between them is a question about the domain, not about the code.

The failure is not choosing wrongly. It is not choosing — which resolves to last-write-wins on every field, because the client that reconnects last is the one whose write the server sees most recently. That is a policy nobody agreed to, applied uniformly, including to the fields where it quietly destroys someone's work.

The server rejected the write because the base version is stale

Two people changed the same thing. Whose version survives, and who is told?

Last-write-wins

when The field is low-stakes, single-owner in practice, or genuinely a snapshot of a current value — a read status, a UI preference, a cursor position.

cost Silent data loss for the other writer. "Last" means "whoever reconnected most recently", which is arbitrary. Never apply it to a field where losing an edit matters.

Field-level merge

when The entity has independent fields and two people editing different ones is common — a form where one edits the title and the other the due date.

cost Merge rules are per field and must be written and tested. Two edits to the *same* field still need one of the other options, so this never removes the decision, it narrows it.

Ask the user

when The field carries work someone would be upset to lose: document bodies, notes, anything with authored content.

cost A conflict UI, a readable diff, strings for every case, and an interruption at a time the user did not choose. It is the only lossless option and the most expensive one.

Refuse and reload

when The change is small, easy to redo, and correctness matters more than convenience — a quantity, a price, a state transition.

cost The user's work is discarded, deliberately and visibly. Honest, and unacceptable if what they lose is more than a moment's typing.

Convergent merge (CRDT-style)

when Genuine multi-writer collaboration where every replica must converge without a coordinator — the point where a mutation queue is the wrong model.

cost A different data model end to end, larger payloads, and a real research burden. Do not adopt it to avoid making the decision above.

How to build it

Most important first.

  • Persist the intent before you show success. The order is: write to IndexedDB, then update the UI, then try the network. Any other order has a window where the user has been told something you cannot honour.
  • Generate an idempotency key per user action, store it with the entry, and reuse it for every attempt. A retry that generates a new key is a duplicate waiting to happen (Idempotency Keys: The Mechanism in API Design).
  • Record the base version with the intent. It is the only thing that lets the server distinguish "apply this" from "apply this to something that has since changed" (Conditional Requests: ETags, 304 and 412 in API Design).
  • Order per entity, parallelise across entities, and stop that entity's chain on its first failure — so one broken task cannot block a different one forever.
  • Make the flush resumable: each entry is removed inside the same transaction that records its success, so a process killed mid-flush loses nothing and repeats at most one attempt.
  • Bound the queue and bound the attempts. Exponential backoff with jitter, a maximum attempt count, and a terminal "needs attention" state that surfaces in the UI rather than retrying forever (Retries, and the Duplicate Order).
  • Elect one flusher across tabs — a Web Lock, or the service worker itself — so a queue is drained once per device, not once per tab (Auth Across Tabs).
  • Decide the conflict policy per field and write it down: which fields are safe to merge, which are last-write-wins, and which must never be resolved without the user. Then build the UI for the third category, because it is the one that gets skipped.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The queue must be announced, not just badged. "3 changes waiting to sync" belongs in a polite live region and in the accessible name of the indicator — a numeral in a coloured circle communicates nothing (Live Regions and Announcement).
  • A conflict is the one state in this lesson that warrants an assertive announcement, because the user must act. Announce it; do not move focus out from under them (Focus Management).
  • A conflict resolution UI must express the difference in text: what the field was, what you changed it to, what the other version says. A colour-coded diff with no text equivalent is unusable by exactly the people least able to reconstruct it.
  • Each pending item should be individually reachable and individually described — what it was, when it was made, what state it is in — so a keyboard or screen-reader user can inspect the queue rather than trusting a summary count.
  • When an item finally syncs, update its description in place rather than removing the row under the user's focus and shifting everything up (Visual Stability).

What can go wrong

Failure modes
  • The lost queue: held in memory, or in localStorage that was cleared, or in a tab that was killed. The user's work is gone and nothing reported it.
  • The duplicate: a retry after a lost response creates a second record. Without an idempotency key the server has no way to know it is the same intent (Idempotency in Backends in Backend Engineering).
  • The reordered apply: a delete overtaking an update for the same entity because the queue parallelised what should have been serialised (Ordering and Duplicate Delivery).
  • The half-drained batch: the loop treats the flush as one operation, so a failure at item six discards items one through five's outcomes or repeats them.
  • The silent overwrite: no base version, so the server applies the change to whatever is current and someone else's edit disappears.
  • The poison entry: a mutation that will never succeed — deleted entity, revoked permission, invalid payload — retried forever, blocking everything behind it (A Dead-Letter Queue Is a Workflow, Not a Bin in Backend Engineering).
  • The expired credential: the session ended while the change sat in the queue, so the flush produces a wall of 401s that look like server errors (Session Expiry and the Refresh Race).
  • The mitigation failing: a conflict dialogue nobody can answer, because the UI shows two opaque JSON blobs instead of what actually differs.
What can arrive out of order
  • A queued mutation versus a concurrent server change: the user edited at version 4, someone else wrote version 5, and the flush arrives carrying version 4. Whether that is a conflict or a silent overwrite depends entirely on whether you sent the base version.
  • The flush racing a refetch: connectivity returns, a background refetch lands fresh server state, and the local UI reverts the user's pending edit before it has been sent (Out-of-Order Responses).
  • Two tabs flushing the same queue concurrently, so each entry is attempted twice from the same device — safe with idempotency keys, duplicated without them.
  • Success racing termination: the server applied the mutation, the response was lost, and the entry is still in the queue. The next attempt must be safe, because from the client's side "applied" and "never arrived" look identical.
  • A retry racing the user editing the same entity again, producing two queued intents for one record whose relative order now decides the result.
Security
  • The queue is user data at rest on a device you do not control. Anything sensitive queued locally is readable by anyone with the device or with script running on the origin (Storage Security and Durability).
  • A queued mutation is not authorized until the server authorizes it. The client may have hidden the action, checked a role and validated the payload — none of that is enforcement, and it must be re-checked at flush time (What the Frontend Is Responsible For in Auth).
  • Clear the queue on logout, and be explicit about what happens to unsent work: silently discarding a user's pending changes is a data-loss bug, and silently syncing them under the next user's session is worse (Session Expiry and the Refresh Race).
  • An idempotency key is a deduplication token, not a capability. It must not be guessable in a way that lets one user's retry collide with another's, and the server must scope deduplication to the authenticated principal (Replay Attacks in Security).
  • A mutation held for hours may carry a credential that has since been revoked. Attach credentials at send time, not at queue time.
Misreads
  • "Retrying makes it reliable." Retrying without an idempotency key makes it *duplicated*. Reliability is retry plus deduplication, and the second half lives on the server (Idempotency in API Design).
  • "The queue is just failed requests." It is a log of user intent. A request is one encoding of an intent, and it may need re-encoding — a different endpoint, a fresh credential, a new base version — before it is sent.
  • "Conflict resolution is a technical detail." It decides whose work survives. That is a product decision with a person on the other end of it.
  • "Last-write-wins is neutral." It means "the last client to reconnect wins", which is a function of who happened to leave the tunnel first, not of who was right.
  • "If I keep them in order everything is fine." Order is necessary and not sufficient: a correctly ordered replay against a base state that has since changed is still an overwrite.
  • "Background Sync means I do not need a flush path in the page." It is not available everywhere and the browser decides when it runs. It is an optimisation on top of a queue that must drain without it.

Measuring it, and what changes in the field

How you would see this
  • Queue depth and queue age as field metrics. Depth tells you about bursts; age — how long the oldest unsent entry has waited — tells you about work that is silently not saved (Real User Monitoring).
  • Flush outcomes broken down by success, retryable failure, permanent failure and conflict. A rising conflict rate is a product signal, not an error rate.
  • Duplicate-detection counts from the server. If the backend is deduplicating a meaningful number of requests, the client is retrying more than it should (The Idempotency Key Flow in Backend Engineering).
  • The Application panel's IndexedDB browser shows the actual records: the fastest way to confirm an entry really persisted before the UI claimed success.
  • Error reports should carry queue depth at the time of the error. A crash with 40 pending mutations is a different incident from the same crash with none (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a long offline period, queue depth grows and reconnect becomes a burst rather than a trickle. Rate-limit the flush from the client, or the server does it for you (Retry Storms: The Load You Generated Yourself in Backend Engineering).
  • On a shared or low-storage device, IndexedDB can be evicted. Durable is a best effort; navigator.storage.persist() asks and may be refused.
  • With multiple tabs, the same queue has several would-be flushers, and without a lock every entry is attempted once per tab.
  • On a slow device, applying a large queue to local state and re-rendering after each item is a main-thread cost that shows up as an unresponsive page precisely when the user has just reconnected.
  • For a collaborative document, per-entity ordering is not enough and you are into merge algorithms — a different problem, and the point at which "queue the mutation" stops being the right model.
What this costs
  • Durability costs an IndexedDB write on the interaction path, before the UI can honestly say the change is safe. It is a real cost and it is not optional.
  • Idempotency keys require server support. Without it, the client can only choose between risking duplicates and risking lost work — and it should choose duplicates loudly rather than silently.
  • Per-entity ordering with cross-entity parallelism is more code than a single serial queue, and a single serial queue means one poison entry blocks every unrelated change behind it.
  • Asking the user to resolve conflicts is the only lossless option and the most expensive: a UI, a diff, strings, and an interruption at a moment they did not choose.
  • Last-write-wins is one line and loses data quietly. It is a legitimate choice for some fields and never a default you should arrive at by not deciding.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe shape — durable intents, idempotency, per-entity ordering, per-item outcomes, an explicit conflict policy — is independent of browser and framework, because it follows from the network being able to lose a response rather than from any API.
  • BROWSER-SPECIFICBackground Sync is a Chromium-family feature and is not implemented in WebKit or Gecko, so a queue that only drains from a sync event will never drain for a large share of users; feature-detect and always keep a page-driven flush path.
  • PLATFORM-SPECIFICHow long a background context is allowed to live before the OS kills it differs sharply between desktop and mobile, and an installed app on iOS may have its storage evicted on a shorter horizon than the same site on desktop — so "queued" is never "guaranteed to be delivered".

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — an offline client is a replica that has been partitioned from the primary. Conflict resolution, eventual consistency and causal ordering are that domain's subject; this lesson is the browser-side instance of it, with a person waiting for the answer.
  • Software Design — the queue entry is a domain object with an invariant ("an intent is durable before it is acknowledged"), and it belongs in a layer that can be tested without a browser.