cf.completefrontend
My progress
FUNDAMENTALS · MODULE 14

Background sync and queued writes

Saving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.

The problem: writes that vanish

Picture a field technician who inspects lifts in office buildings. She fills in a checklist on her phone in the basement machine room, taps Submit, and walks to the next site. The basement has no signal. The fetch call fails after a few seconds, the app shows a red toast she never sees, and the report is gone.

Reading data offline is the easier half of the problem. A service worker (a script the browser runs apart from the page, sitting between it and the network) can answer page loads and API reads from a cache, as the lesson on service worker caching shows. Writing is harder, because a write is something the user did. If the app drops it, the user has lost work, and they usually find out much later, when it is expensive to fix.

Background sync is the name for the pattern that fixes this: instead of sending a write straight to the server and hoping, the app stores it in a durable queue on the device, tells the user it is saved locally, and sends it when the network allows. The word "background" means the sending can happen later, without the user waiting on it, and in some browsers even after the tab is closed.

What goes wrong without a queue

A plain fetch on submit fails in several distinct ways. Each one needs a different answer, which is why a queue is more than a retry loop.

  • No network at all. The request fails at once with a TypeError. The write never left the device. Retrying later is safe.
  • The network dies mid-request. The request reached the server, the server saved the report, but the response never came back. The client sees a failure. If it retries blindly, the server now has two reports. This is the double-submit problem, and it is the most important case in this lesson.
  • The user closes the tab. Any retry timer in page memory dies with it. Without something stored on disk, the write is lost even though the user did everything right.
  • Flaky network. On a train, requests succeed and fail at random. Some writes in a batch land and some do not, possibly out of order. An "edit comment" can reach the server before the "create comment" it depends on.
  • The server says no. A 400 or 422 means the write itself is wrong. Retrying it forever will never work, and it can block every write behind it.
Two columns compare the same submit in a basement with no signal. Without a queue: tap Submit, fetch fails, the error toast is never seen, and the report is lost. With an outbox: tap Submit, the report is saved to the outbox, the phone is offline for 40 minutes, and then the report is sent once and the server answers 201.plain fetch on submitoutbox, then sendtap Submitfetch failstoast never seenreport is losttap Submitsaved to outboxoffline 40 minsent once: 201
The user does the same thing in both columns. The only difference is whether the write is stored on the device before the network is involved.

The promise you make to the user

A good queued-write system makes three promises, and the rest of this lesson is about keeping them:

  1. Nothing is lost. Once the app says "saved", the write survives a closed tab, a reload, a crash, and a phone restart.
  2. Nothing is applied twice. However many times the client retries, the server applies each write once.
  3. The user always knows where things stand. Pending, sending, failed, or done: the state of each write is visible, and a failed one can be retried or discarded.

Notice that none of these promises is "the write reaches the server immediately". Offline, that promise is impossible. What you can promise is durability, exactly-once effect, and honesty. Apps that keep these three promises, such as Linear, Gmail with its Outbox, and good field-service apps, feel trustworthy offline.

The mental model: an outbox and a courier

Think of an office post tray. You drop a letter in the tray and go back to work. A courier comes by when they can, takes the letters in order, and delivers them. Each letter carries a reference number, so if the courier is unsure whether a delivery went through and brings the same letter twice, the receiver sees the number and files it once.

Map that onto the browser and you have the whole design. The tray is an outbox: a store in IndexedDB, the browser's built-in database, that holds writes not yet confirmed by the server. The courier is a sync engine: a small piece of code, in the page or in the service worker, that reads the outbox and sends entries. The reference number is an idempotency key: a unique id created once, when the user acts, and sent with every attempt.

An architecture in three bands. At the top, the page holds the UI and the sync engine, and next to it the service worker holds a sync event handler, drawn dashed because only Chromium has it. In the middle, IndexedDB, on disk, holds two stores: reports and outbox. The UI writes to both in one transaction. The sync engine and the sync handler both read the outbox. At the bottom, the server has the API, which receives POST requests with an Idempotency-Key, and a key store it uses to drop repeats.PageUIrenders reportssync engineflushOutbox()Service workersync handlerChromium onlyIndexedDBreportsoutboxUI writes both storesin one transactionServerAPIdedupes by keykey storePOST + Idempotency-Key
The UI never talks to the network. It writes to IndexedDB, and either the page's sync engine or the worker's sync handler sends what is in the outbox.

Two halves that never wait on each other

The key idea is that saving and sending are separate steps, done by separate code at separate times.

  • Saving happens when the user acts. The UI writes the change to local data (so the screen updates) and appends an entry to the outbox, in the same IndexedDB transaction. This takes a few milliseconds and never touches the network. Once it commits, the write is safe.
  • Sending happens whenever the engine is woken up: right after saving, when the browser reports it is back online, when the app opens, or when the service worker gets a sync event. The engine takes the oldest entry, sends it, and deletes it only after the server confirms.

Because the UI only ever talks to the outbox, the UI code is the same online and offline. That is the core benefit. The lesson on offline-first design makes the local store the only truth the UI reads; the outbox is the write path of that design.

The life of one entry

Each outbox entry moves through a small set of states. Drawing them as a state machine forces you to answer the hard questions: what happens when sending fails, and who decides that an entry is dead?

A state machine for one outbox entry. The user acts and the entry starts as pending, saved and waiting. A flush moves it to sending, with a request in flight. From sending, a 2xx moves it to done, where the entry is deleted. A 400 or 422 moves it to failed, which needs the user. Being offline or a 5xx moves it to retry wait, with a nextAttemptAt time. When the backoff ends, it goes back to pending. From failed, the user can tap Retry, which moves it back to pending.user actspendingsaved, waitingsendingrequest in flightdoneentry deletedfailedneeds the userretry waitnextAttemptAtflush2xx400, 422offline, 5xxbackoff endsuser taps Retry
Every arrow out of sending is decided by the response. Only two states are final: done deletes the entry, and failed waits for a person.
  • pending: saved locally, waiting for its turn. This is where every entry starts.
  • sending: a request is in flight. If the tab dies here, the entry must fall back to pending on the next start, never stay stuck in sending.
  • done: the server confirmed with a 2xx. The entry is deleted from the outbox, and the local record is updated with anything the server returned, such as its final id or timestamp.
  • waiting to retry: a temporary failure (offline, timeout, 503, 429). The entry goes back to pending with a later nextAttemptAt and a higher attempt count.
  • failed: a permanent failure (400, 403, 422) or too many attempts. The entry stops sending and waits for the user to retry, edit, or discard it.

Think first

Can a client-side queue ever guarantee that a request is sent exactly once?

Building the outbox in IndexedDB, step by step

IndexedDB is the only browser store that fits. It survives reloads and restarts, holds structured data and Blobs, works with transactions, and, unlike localStorage, can be read from a service worker, which is where a background sync event runs. The lesson on IndexedDB covers its API; here we only need one object store.

What an entry holds

Store a plain description of the write, not a Request object. A description can be inspected, edited, merged with later writes, and replayed with fresh headers. A frozen request cannot.

ts
type OutboxEntry = {
  seq?: number;          // autoIncrement key: the global order
  id: string;            // crypto.randomUUID(), also the Idempotency-Key
  op: "createReport" | "updateReport" | "deleteReport";
  entityId: string;      // which record it touches, for ordering
  payload: unknown;      // the JSON body, or a Blob for files
  status: "pending" | "sending" | "failed";
  attempts: number;
  nextAttemptAt: number; // ms since epoch, for backoff
  createdAt: number;
  userId: string;        // whose write this is
  lastError?: string;
};
No URL with a baked-in token, no Authorization header: those are added at send time.

Three fields deserve a word. seq is an auto-incrementing key that IndexedDB assigns, so reading the store in key order gives the order the user acted in. id is created once, when the user taps the button, and never changes across retries: that is what makes it an idempotency key. userId lets you refuse to send one person's writes under another person's session after a sign-out on a shared device.

The outbox object store as a table of four entries in seq order, with columns seq, id, op, entityId, status, and tries. Entry 1, key k-7f3c, createReport for rep_7f3c, is sending on its first try. Entry 2, key k-91aa, updateReport for the same rep_7f3c, is pending. Entry 3, key k-0b52, createReport for rep_88d1, is pending. Entry 4, key k-c410, deleteReport for rep_1204, has failed after 12 tries. Notes say that seq is the order the user acted and the flush reads from the top, and that the id is the Idempotency-Key, the same on every retry.seqid (key)opentityIdstatustries1k-7f3ccreateReportrep_7f3csending12k-91aaupdateReportrep_7f3cpending03k-0b52createReportrep_88d1pending04k-c410deleteReportrep_1204failed12seq: the order the user actedthe flush reads from the topid: the Idempotency-Keythe same on every retry
Entries 1 and 2 touch the same report, so entry 2 must wait for entry 1. Entry 4 has given up and moved aside; it no longer blocks anything that does not depend on it.

Enqueue in the same transaction as the local change

When the technician submits a report, two things change: the local reports store (so the list shows the report) and the outbox store (so it gets sent). Do both in one IndexedDB transaction. If the tab dies between two separate transactions, you get a report on screen that will never be sent, or a send for a report the screen never showed.

ts
async function submitReport(report: Report) {
  const db = await openDb();
  const tx = db.transaction(["reports", "outbox"], "readwrite");
  tx.objectStore("reports").put({ ...report, syncState: "pending" });
  tx.objectStore("outbox").add({
    id: crypto.randomUUID(),
    op: "createReport", entityId: report.id, payload: report,
    status: "pending", attempts: 0, nextAttemptAt: 0,
    createdAt: Date.now(), userId: currentUser.id,
  });
  await tx.done;            // using the idb wrapper
  requestFlush();           // wake the courier; do not await it
}

The flush loop

The sending side is a loop that runs until the outbox is empty or something stops it. Keep it boring and predictable:

  1. Take a lock so only one flusher runs at a time (more on this in the failures chapter).
  2. Read the oldest entry in seq order whose status is pending and whose nextAttemptAt has passed.
  3. Mark it sending, then call the server with the entry's id in an Idempotency-Key header and a fresh auth token.
  4. On 2xx: in one transaction, delete the entry and update the local record (syncState: "synced", server fields). Go to step 2.
  5. On a temporary failure: set status back to pending, raise attempts, set nextAttemptAt with backoff, and stop the loop.
  6. On a permanent failure: set status to failed, keep lastError, and decide whether later entries can still go (see ordering).

Why stop the whole loop on a temporary failure? Because if one request just timed out, the next one will almost certainly time out too. Hammering a dead network wastes battery and, with a struggling server, makes the outage worse. The lesson on retries and backoff explains the delay curve.

A typical outbox waits 1 s, 2 s, 4 s, and so on, up to a cap of about 5 minutes, with random jitter. That cap is much longer than the 8 to 30 s used for requests a person is watching, because nobody waits on the screen for a queued write.

On startup, before the first flush, reset any entry stuck in sending back to pending. An entry can only be in sending after a restart if the previous run died mid-request, which is exactly the case where the server may or may not have it. The idempotency key will sort that out.

Coalescing: fewer, smarter entries

A user offline for an hour may rename a document 40 times. Sending 40 updates is wasteful. Before appending, you can merge with an existing pending entry for the same entity: two updates become one update with the later fields, a create followed by an update becomes one create with the final data, and a create followed by a delete can remove both entries entirely.

Only merge entries that are still pending, never one in sending, because that one may already be on the server. And keep merging rules per operation type: merging is safe for "set title to X", unsafe for "add 1 to the counter" unless you add the amounts.

Check your understanding · Exercise 1

Which of these are good choices for an outbox entry? Select all that apply.

When to send: the Background Sync API and its fallbacks

The outbox decides what to send. Something still has to decide when. There are two families of triggers: ones that run inside an open page, and the Background Sync API, which lets the browser wake your service worker even after every tab of your site is closed.

The one-off Background Sync API

The page registers a sync tag, a string that names a job. The browser remembers the tag and fires a sync event in the service worker as soon as it believes the device has a connection: at once if it is online, later if not. The worker flushes the outbox inside event.waitUntil(), which keeps it alive until the promise settles.

ts
// page: after enqueueing
async function requestFlush() {
  const reg = await navigator.serviceWorker.ready;
  if ("sync" in reg) {
    try { await reg.sync.register("outbox"); return; }
    catch { /* permission denied or not allowed: fall through */ }
  }
  void flushOutbox(); // no Background Sync: send from the page
}

// service worker
self.addEventListener("sync", (event) => {
  if (event.tag !== "outbox") return;
  event.waitUntil(flushOutbox({ lastChance: event.lastChance }));
});
The same flushOutbox function runs in the page and in the worker, so it must use only APIs both have: IndexedDB, fetch, and Web Locks.
A sequence diagram with Page, IndexedDB, Browser, Worker, and API. The page adds an entry to IndexedDB and calls sync.register with the tag outbox. The tab is closed and the device is offline for 25 minutes. When the network is back, the browser fires a sync event in the worker. The worker reads pending entries from IndexedDB, sends a POST with the idempotency key to the API, gets 201 Created, deletes the entry, and its waitUntil promise resolves, so the browser drops the registration.PageIndexedDBBrowserWorkerAPIadd entryregister(outbox)tab closed, offline for 25 minsync eventread pendingPOST + key201delete entryresolved
After the second arrow the page can be gone. If the worker's promise had rejected, the browser would keep the tag and fire sync again later: Chrome allows three attempts in all, about 5 and then 15 minutes apart.

The details that matter in practice:

  • Tags coalesce. Registering "outbox" ten times while a sync is pending gives one event, not ten. That is what you want: one event drains the whole queue. Use different tags only for truly separate jobs.
  • Rejecting means "try later". If the waitUntil promise rejects, the browser schedules another attempt with a growing delay. Chrome's defaults allow three attempts in all: the first, a retry about 5 minutes later, and a last one about 15 minutes after that. Do not treat the API as a fast retry loop.
  • event.lastChance is true on the browser's final attempt for that registration. Use it to mark remaining entries so the page shows them as needing attention on the next visit. The entries stay in IndexedDB either way: giving up on the event is not giving up on the write.
  • Time limit. The browser will not keep a service worker alive forever for one sync event: Chrome ends it after 3 minutes by default. Flush in small steps and keep state in IndexedDB so a cut-off run can resume.
  • Support. One-off Background Sync ships in Chromium browsers (Chrome, Edge, Opera, Samsung Internet). Firefox and Safari do not support it as of 2026. Always feature-detect with "sync" in registration.

Periodic Background Sync is a different tool

The similarly named Periodic Background Sync API (registration.periodicSync.register(tag, { minInterval })) fires a periodicsync event on a rough schedule, for refreshing content such as news before the user opens the app. It is not for writes.

It only works in Chromium, and only for a progressive web app (PWA) the user has installed and launched as an app. Chrome then decides the real interval from the site engagement score, a measure of how much the user uses the site: never more often than every 12 hours, less often for rarely used apps, and sometimes not at all. Do not build a feature that depends on it.

Triggers that work everywhere

Since Safari and Firefox have no Background Sync, every outbox needs page-side triggers. In fact they are the main path; Background Sync is the bonus that covers "the user closed the tab" in Chromium. Wire up all of these to the same flushOutbox:

  • Right after enqueueing. Most of the time the network is fine and the write goes out within 100 ms.
  • The online event. It fires when the browser's own view flips from offline to online. Treat it as a hint: navigator.onLine === true only means "some network interface is up", not "your server is reachable".
  • App start and visibilitychange to visible. The user reopens the app on the bus: flush. This covers browsers where the tab was closed with writes still queued.
  • After any successful request. If a read just worked, the network is back, even if no online event fired.
  • A backoff timer. When an entry is waiting to retry, schedule a setTimeout for its nextAttemptAt while the page is open.
A flowchart. Five triggers on the left all call flushOutbox: after enqueue, the online event, the app opening or becoming visible, the sync event, drawn dashed because only Chromium fires it, and a backoff timer. flushOutbox takes a Web Lock and returns if another tab or the worker holds it. With the lock, it sends the oldest entry that is due. On a 2xx it loops to the next entry. When the queue is empty, or on an error, it stops and waits for the next trigger.after enqueueonline eventapp openssync eventbackoff timerflushOutbox()one for page and SWtake Web Lockif held, returnsend oldest duewith its key2xx: nextempty or errorwaitfor the next trigger
Many triggers, one function, one lock. The dashed trigger is the only one that can run with no tab open; the other four are what Safari and Firefox users rely on.

Decision Who sends the queued writes?

Page only

The page flushes on enqueue, online, app start, and a timer.

No service worker code involved.

Pros

  • Works in every browser.
  • Simplest to debug: normal devtools, normal auth.

Cons

  • Writes wait until the user opens the app again.
  • Two tabs can flush at once unless you add a lock.

Background Sync plus page fallback

Feature-detect sync; register a tag after enqueueing.

Keep every page trigger as well, sharing one flush function and one lock.

Pros

  • In Chromium, writes go out even after the tab is closed.
  • Degrades cleanly elsewhere.

Cons

  • Flush code must run in both page and worker.
  • Two paths to test, and the worker path is harder to observe.

Workbox BackgroundSyncPlugin

Add the plugin to a NetworkOnly route for POST requests.

Requests that fail with a network error are stored in IndexedDB and replayed on sync, or on worker start where sync is missing.

Pros

  • Ten lines of config for simple fire-and-forget writes.
  • Handles storage, ordering, and replay for you.

Cons

  • Only network errors are queued: a 500 or 422 response counts as delivered and is not retried or shown.
  • Stores whole requests, headers included, so expired tokens replay as-is.
  • The page does not easily see the queue, so a pending badge needs extra work.
  • No coalescing, no temp id rewriting, no per-entity ordering.

What we pick: For a form or an analytics beacon, the Workbox plugin is fine. For an app where users see and manage their pending writes, build your own outbox with Background Sync as an optional trigger and page-side triggers as the main path.

js
import { BackgroundSyncPlugin } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";

registerRoute(
  ({ url }) => url.pathname.startsWith("/api/reports"),
  new NetworkOnly({
    plugins: [new BackgroundSyncPlugin("reports", { maxRetentionTime: 24 * 60 })],
  }),
  "POST",
);
maxRetentionTime is in minutes: requests older than 24 hours are dropped instead of replayed.

Check your understanding · Exercise 2

A user queues three writes in Safari, then closes the tab while still offline. Which statements are true? Select all that apply.

Idempotency: retry without double submits

Back to the technician. Her phone sends the report, the server saves it and sends 201 Created, and the lift doors close before the response arrives. Her phone sees a network error. The outbox does its job and retries a minute later.

Without protection, the building now has two inspection reports, and if the write had been a payment, she would have paid twice.

An operation is idempotent when doing it twice has the same effect as doing it once. PUT /reports/42 with the full report is naturally idempotent: the second call writes the same data again. DELETE /reports/42 is too, as long as the second call answers 404 or 204 without fuss.

POST /reports is not: each call creates a new row. Queued writes are mostly creates, so you need to make them idempotent on purpose.

Step 1 of 7

The technician taps Submit. The report and an outbox entry are saved in one transaction. The entry gets the key k-7f3c now, once.

A sequence diagram with Phone, API, and Database, at step 1 of 7, with an idempotency key. The technician taps Submit. The report and an outbox entry are saved in one transaction. The entry gets the key k-7f3c now, once. The reports table holds nothing yet.PhoneAPIDatabaseoutbox: k-7f3creports table
The client cannot tell a lost response from a lost request, so it must retry. Only the server, holding the key, can tell that the second POST is a repeat.

Two ways to make a create safe to repeat

  • Client-generated ids. The client makes the id (crypto.randomUUID()) and sends PUT /reports/7f3c... or a POST whose body includes the id. The server does "insert if this id does not exist". A retry finds the row and does nothing. This is the cleanest option when you own the API, and it also removes the temp id problem in the next chapter.
  • An idempotency key header. The client sends Idempotency-Key: 7f3c... with a normal POST. The server remembers which keys it has processed and what it answered. This works for any operation, including ones with side effects such as payments or emails, and is the approach Stripe made popular. An IETF draft proposes a standard for the header.

What the server must do with the key

The header does nothing on its own: the server has to keep a record of keys. A careful implementation stores, per key, the user, a hash of the request body, a status, and the saved response.

  1. New key: record it as in progress, run the operation, store the response, and mark it done, ideally in the same database transaction as the operation itself.
  2. Known key, done, same body: do not run anything. Return the stored response, the same 201 with the same report id. The client cannot tell it apart from the first answer, which is the point.
  3. Known key, still in progress: a second copy arrived while the first is still running. Answer 409 Conflict so the client retries later instead of running it twice in parallel.
  4. Known key, different body: a client bug reused a key. Answer 422 and do not run it.

Keys are scoped to a user, so one person's key cannot replay another's response. They are kept for a fixed window: Stripe keeps them for at least 24 hours. The window must be longer than the longest time an entry can sit in your outbox, or a write queued on Friday and sent on Monday could be applied twice. That is a real reason to cap how long entries can wait.

http
POST /api/reports HTTP/1.1
Idempotency-Key: 7f3c2a9e-5b1d-4e8a-9c0f-2d6b8e1a4f77
Content-Type: application/json

{"id":"rep_7f3c","liftId":"L-118","result":"pass"}

HTTP/1.1 201 Created
Idempotent-Replayed: true

{"id":"rep_7f3c","createdAt":"2026-03-04T09:12:44Z"}
The second attempt gets the stored answer. A replay marker header is a common convention (Stripe sends one), not a standard.

Think first

The user double-clicks Submit while online. Does the idempotency key help?

Check your understanding · Exercise 3

Which requests are safe to retry blindly, without an idempotency key or client id? Select all that apply.

Ordering and dependencies between queued writes

Offline, a user rarely makes one write. They create a checklist, add three items to it, tick one, and rename the checklist. Those five writes depend on each other: an item cannot be added to a checklist the server has never heard of. If they reach the server in the wrong order, the server rejects some and the user's work is half-applied.

How order breaks

  • Parallel sending. Sending five entries at once with Promise.all lets the network reorder them. The update can win the race against the create.
  • Retry reordering. Entry 1 fails with a timeout and goes to the back; entries 2 to 5 go out first.
  • Two flushers. A page and the service worker, or two tabs, each pick up different entries at the same moment.
  • Server-assigned ids. The create returns the real id rep_9912, but the update was queued against the temporary id tmp-1, which means nothing to the server.
A dependency graph of six queued writes. Inside a group for checklist C: write 1 creates C. Writes 2 (add item i1), 4 (rename C), and 3 (add item i2) all depend on write 1. Write 5 (tick i1) depends on write 2. Outside the group, write 6 creates checklist D and has no dependencies.group: checklist C1create C2add item i14rename C3add item i25tick i16create Down group
Arrows point from a write to the writes that need it on the server first. Everything in the group must go in order; write 6 can go at any time, even if write 1 is stuck.

Rule one: send in order, one at a time

The simplest safe rule is strict FIFO: send the oldest entry, wait for its answer, then send the next. Writes reach the server in the order the user made them. The cost is speed: 50 entries at 150 ms each take 7.5 seconds instead of under one. For most apps after a short offline spell, that is fine.

The harder question is what to do when the head of the queue fails permanently. Strict FIFO blocks everything behind it, which is correct for entries that depend on it and needlessly harsh for unrelated ones. This is head-of-line blocking.

Rule two: order per entity, parallel across entities

A better rule is to keep order only where it matters. Group entries by the record they touch (entityId, or the root record, such as the checklist for its items). Within a group, send strictly in order.

Across groups, send in parallel, a few at a time. A failed entry blocks only its own group: a rejected comment on report A does not stop the photos for report B.

Grouping by entity works when dependencies stay inside one record tree. When they cross trees (an item moved from one checklist to another), put both trees in one group for that entry, or fall back to global FIFO. Keep the rule simple enough that you can explain it in one sentence.

Temporary ids, and why to avoid them

If the server assigns ids, the client has to invent a temporary one (tmp-1) to show the new record and to queue later writes against it. When the create succeeds and returns rep_9912, the client must, in one transaction: update the local record's id, rewrite every pending entry that mentions tmp-1, and fix any references in other local records and in the URL. Miss one and a later write fails with 404. The lesson on optimistic updates walks through this remapping.

The cleaner answer is to let the client choose the final id: a UUID or a sortable id such as a UUIDv7 or ULID, created on the device. The server accepts it as the primary key. There is nothing to remap, the create is naturally idempotent, and later entries can reference the record immediately. Most modern local-first systems work this way.

Lab: break it on purpose

The simulator below is an outbox with five queued writes for one checklist. It starts offline: queue some writes, then come back on a flaky network where one response in three is lost after the server has applied the write. Switch idempotency keys and ordering off and on. Watch duplicate rows appear on the server, and writes rejected with 422 turn red in the outbox.

Network:

Outbox: 0 pending, 0 failed. Server: 0 rows, 0 duplicates. Attempts so far: 0.

Start offline: queue a few writes, then switch to the flaky network and flush until the outbox is empty.

On the left, the outbox: empty. On the right, the server: no rows.outbox, on the phoneserver rowsnothing queued
Outbox entries are marked pending, failed, or done. Server rows that exist twice are marked as duplicates. Try the flaky network with keys off, then all at once on a good network.

Check your understanding · Exercise 4

An outbox holds: (1) create checklist C, (2) add item to C, (3) create checklist D, (4) rename C. Entry 1 fails with 422. Under per-entity ordering, which entries can still be sent? Pick one.

Failures, poison messages, and giving up

A queue that retries everything forever is as broken as one that retries nothing. The sync engine has to sort every failure into one of three buckets, quickly and without asking the user: try again later, fix something and try again now, or stop and ask a human.

Triage each response

ResultMeaningWhat the engine does
Network error, timeoutNever reached the server, or the answer was lostKeep the entry, back off, stop the loop. The key makes the retry safe.
408, 429, 502, 503, 504Server busy or briefly downBack off, respecting Retry-After if present. Stop the loop.
401Token expired while the entry waitedRefresh the token once and retry now. If refresh fails, pause the queue and ask the user to sign in.
409 on the keySame key still being processedRetry after a short delay.
409 or 412 on the dataSomeone else changed the recordHand it to conflict resolution: merge, rebase, or ask the user.
400, 403, 404, 422The write itself is invalid or not allowedMark failed, keep the error, block its group, tell the user.
2xxApplied, maybe on an earlier attemptDelete the entry, apply the server's response locally.
A decision tree for one response. A 2xx deletes the entry. A 5xx or a timeout backs off; if there have been too many tries, 12 tries or 24 hours, the entry is marked failed and moved aside. A 401 refreshes the token once; if the refresh fails, the queue is paused and the user is asked to sign in. A 400 or 422 marks the entry failed at once.response?2xxdelete entry5xx, timeoutback off401refresh once400, 422mark failedtoo many?12 tries, 24 hrefresh ok?token endpointyesfailedmove asidenosign inqueue paused
Two branches end in a person: a write the server will never accept, and a session that has ended. Every other branch is handled by the engine on its own.

Poison messages

A poison message is an entry that can never succeed: a form saved by an old app version with a field the server no longer accepts, a comment on a record someone deleted, a body the server crashes on with 500 every time. If the engine keeps retrying it at the head of the queue, nothing behind it is ever sent.

Protect against it with a give-up policy that does not depend on recognising the cause. The numbers below are an example to tune for your app, not a standard:

  • Cap attempts. After, say, 12 failed attempts while online (about 20 minutes with delays that start at 1 s and double up to a 5-minute cap), treat the entry as failed even if every error looked temporary. A 500 that repeats for 20 minutes is not a blip. Time spent offline does not count: when the browser reports it is offline, the engine waits for the online event instead of spending attempts.
  • Cap age. An entry older than your idempotency key window (24 hours is common) must not be sent blindly, because the server may have forgotten the key. Mark it failed and let the user confirm.
  • Move it aside. Failed entries leave the sending path, like a dead-letter queue on a server. Only entries that depend on them stay blocked.
  • Never delete silently. A failed entry is still the user's work. Keep it until the user retries, edits, or discards it, and log it to your error tracker with the server's error body.
A bar chart of the wait before each retry of one entry, to scale. Before attempt 2 the engine waits 1 second, then 2, 4, 8, 16, 32, 64, 128, and 256 seconds, then 300 seconds before attempts 11 and 12, because the wait is capped at 5 minutes. After attempt 12, about 20 minutes after the first, the entry is marked failed.cap: 5 min2345616s732s864s9128s10256s11300s12300sfailedattempt number; waits under 16 s are too short to label
Doubling makes the first retries quick and the later ones rare. The cap keeps a long outage from pushing the next try hours away, and the attempt limit turns a write that never works into a failed entry instead of an endless loop.

Auth tokens that expire while queued

Access tokens commonly live 5 to 60 minutes. A write queued in the morning and sent in the evening will meet an expired token. That is why the entry stores no token: the engine asks the auth layer for a fresh one at send time, and on 401 runs the normal refresh flow once before retrying. The lesson on auth tokens covers the refresh flow itself.

  • In a service worker, the page's in-memory token is out of reach. Cookie-based sessions work naturally, because fetch from the worker sends same-origin cookies. Bearer tokens need a refresh path the worker can run on its own, or the worker should skip the flush and leave it to the page.
  • If the refresh fails, the user's session is over. Pause the queue, keep every entry, and show "Sign in to send 3 saved changes". Discarding them would punish the user for a timeout.
  • On sign-out, decide explicitly. On a shared device, clear the outbox (after warning about unsent changes). Never send user A's entries with user B's token: check entry.userId against the current user before every send.
  • CSRF tokens (values that prove a request came from your own pages) expire too. If your API needs one, fetch a fresh token at send time rather than storing it in the entry.

Other edges that bite in production

  • Two tabs, or a tab and the worker, flush at once. Wrap the flush in navigator.locks.request("outbox", ...) from the Web Locks API, which works in pages and workers. Only one holder runs; the others wait or skip.
  • Storage eviction. Under storage pressure the browser can clear a site's data. Safari also deletes a site's script-written storage, outbox included, after 7 days of Safari use without the user interacting with the site; a Home Screen web app keeps its own day counter, which resets each time it is opened. Call navigator.storage.persist() for apps where queued writes matter, and keep queues short-lived.
  • App updates. A new release may change the shape of a payload. Store a schemaVersion on each entry and migrate old entries on startup, or keep the old serializer around until the queue drains.
  • Large files. A queued 40 MB video should not be retried from byte zero on every flaky attempt. Use a resumable upload protocol and store the upload session id in the entry.

Check your understanding · Exercise 5

An entry has failed 3 times. Which next steps are right? Select all that apply.

Telling the user what is happening

The outbox is invisible machinery. What the user sees decides whether they trust it. Show too little and they submit the report again "to be sure", creating a real duplicate the key cannot catch. Show too much and every flaky second turns into alarming red banners.

Four states, each with its own wording

StateWhat to showWhere
Saved locally, waiting"Saved on this device. Will send when you're online." A small clock icon.On the item itself, plus a count in the header
SendingA subtle spinner on the item. No toast.On the item
SyncedRemove the pending marker. Optionally "All changes saved" in the header.Header status
Failed"Couldn't send: the lift ID L-118 no longer exists." With Retry, Edit, and Discard.On the item, plus a list in a sync panel

Word the waiting state as a fact about safety ("saved on this device"), not as an error ("failed to send"). The user did nothing wrong, and their work is safe. Reserve warning colors for writes that need their action.

A wireframe of an Inspections app. The header says 1 waiting, 1 failed. Three items are listed. Lift L-114 pass is marked synced. Lift L-117 fail is marked saved on device, waiting. Lift L-118 pass is marked couldn't send: lift not found, with Retry, Edit, and Discard buttons under it. At the bottom, a status line reads 1 change waiting to send, 1 failed.InspectionsToday1 waiting, 1 failedLift L-114: passsyncedLift L-117: failsaved on device, waitingLift L-118: passcouldn't send: lift not foundRetryEditDiscardrole=status: 1 waiting to send, 1 failed
The header answers "is everything safe?", each item answers "is this one safe?", and only the failed item asks for action.

A global indicator

Per-item markers answer "is this one safe?". A small header indicator answers "is everything safe?": "3 changes waiting" with a link to a sync panel that lists every pending and failed entry. The panel is where Retry all and Discard live. Gmail's Outbox folder is the familiar version of this: messages that are written but not yet sent, in one place.

Do not flip the indicator to "offline" on the first failed request. The offline-first lesson suggests waiting for about three failures in a row, or the offline event, before changing the global state, so a single dropped request on good Wi-Fi does not flash a banner.

Retry, edit, and discard

  • Retry resets attempts and nextAttemptAt, sets the entry back to pending, and flushes. It keeps the same idempotency key, because it is the same write.
  • Edit opens the form with the entry's payload. Saving replaces the payload and creates a new key, because it is now a different write.
  • Discard deletes the entry and rolls back the local record, the same rollback an optimistic update does on failure. Ask for confirmation, and mention dependent entries that will go too ("This will also discard 2 items added to this checklist").

Accessibility

  • Announce changes in the global state through a polite live region (role="status"): "3 changes waiting to send", then "All changes saved". Do not announce every single entry.
  • Announce a newly failed write once, assertively only if it blocks the user's current task.
  • Never rely on color or an icon alone: the clock icon needs text or an accessible name such as "Pending sync".
  • Retry and Discard are real button elements in the tab order, with names that include the item ("Retry sending report for lift L-118").

Leaving with unsent work

If the outbox has entries and the browser has no Background Sync, closing the tab means nothing is sent until the next visit. A beforeunload prompt is tempting, but browsers show only a generic message, and it is easy to abuse. Use it only when there are entries the user would be hurt to lose and only for the first leave, and prefer clear in-app wording ("3 changes will send next time you open the app") over blocking the exit.

Check your understanding · Exercise 6

Which feedback choices are good for queued writes? Select all that apply.

In real products and in the interview

How real products do it

The pattern shows up wherever losing a user's write is expensive. The details differ, but each of these makes the same three choices: store first, send with a stable id, and show the state.

  • Stripe made idempotency keys a mainstream API pattern: every POST accepts an Idempotency-Key, the first result is stored and replayed for repeats, and keys expire after at least 24 hours. Many payment and fintech APIs copied the design, and an IETF draft proposes a standard for the header.
  • Gmail keeps an Outbox folder: mail written while offline is saved and shown there until it goes out. It is the clearest example of making the queue visible to users.
  • Linear has described its sync engine in public talks: changes apply at once to a local copy of the data kept in IndexedDB, and go to the server in the background as a queue of transactions.
  • Workbox, Google's service worker library, ships workbox-background-sync: the BackgroundSyncPlugin stores failed requests in IndexedDB and replays them on the sync event, or each time the service worker starts in browsers without Background Sync.
  • Field-service and inspection apps (the technician in this lesson) are where the full pattern is a hard requirement: technicians work for hours without signal, and losing a signed inspection has legal consequences.

When to bring it up

Raise queued writes whenever the problem includes unreliable networks or user-created content that must not be lost: a chat app, a notes or docs editor, a to-do list, a form on mobile, a checkout. Even in an online-only design, mention idempotency keys on the submit endpoint: double-submits and lost responses happen on perfectly good networks too.

What to draw

Five boxes and one state machine are enough. Draw them in this order, and talk through one write from tap to 201 before you go into failures.

  1. The UI writing to a local store and an outbox in one transaction.
  2. A sync engine reading the outbox, with its triggers: enqueue, online, app start, Background Sync where supported.
  3. The request carrying an Idempotency-Key, and a key store on the server next to the database.
  4. The entry state machine: pending, sending, done, waiting to retry, failed.
  5. The UI states: pending badge, header count, failed item with Retry and Discard.
LevelWhat a strong answer covers
JuniorQueue writes in IndexedDB instead of dropping them; retry when back online; show a pending state.
MidIdempotency keys created at enqueue time; Background Sync with feature detection and page fallbacks; FIFO sending; delete only after 2xx; Retry and Discard for failures.
SeniorAt-least-once delivery plus server dedupe as exactly-once effect; per-entity ordering and client-generated ids; error triage and poison entries; token expiry, multi-tab locks, eviction, schema versions; key retention window tied to the maximum queue age.

Check your understanding · Exercise 7

You are designing an expense app used on flights. Which belong in a strong design? Select all that apply.

Summary

  • Saving and sending are separate steps: write to local data and an IndexedDB outbox in one transaction, and let a sync engine send later.
  • Delete an entry only after a 2xx. The client delivers at least once; idempotency keys on the server turn that into exactly-once effect.
  • Create the idempotency key when the user acts, store it with the entry, and keep it on the server longer than an entry can wait.
  • Background Sync is a Chromium-only bonus that sends after the tab closes. Page-side triggers (enqueue, online, app start, after any success) are the main path.
  • Keep order per entity and prefer client-generated ids, so dependent writes never race and temp ids never need remapping.
  • Triage every failure: back off on temporary ones, refresh on 401, and move permanent or repeating failures aside as failed without deleting them.
  • Show pending, sending, and failed states on each item plus a global count, with Retry, Edit, and Discard, and announce changes politely to screen readers.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How would you make a form submission survive going offline?

Question 2

The request timed out but the server may have saved it. How do you retry safely?

Question 3

What does the Background Sync API give you, and what can't you rely on it for?

Question 4

A user creates a list offline, adds items, then renames it. How do you keep those writes consistent?

Question 5

What can go wrong with writes that wait in a queue for hours?

Practice it

These design problems lean on background sync and queued writes.

References

Read next