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
400or422means the write itself is wrong. Retrying it forever will never work, and it can block every write behind it.
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:
- Nothing is lost. Once the app says "saved", the write survives a closed tab, a reload, a crash, and a phone restart.
- Nothing is applied twice. However many times the client retries, the server applies each write once.
- 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.
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
syncevent. 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?
- 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 laternextAttemptAtand 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?
No. The client can never tell "the server never got it" from "the server got it but the answer was lost". So a client that never loses writes must sometimes send twice: it delivers at least once.
Exactly-once is an effect you build on top: at-least-once delivery from the client, plus deduplication by idempotency key on the server, gives exactly-once processing. Say it in that order in an interview.
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.
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;
};
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.
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.
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:
- Take a lock so only one flusher runs at a time (more on this in the failures chapter).
- Read the oldest entry in
seqorder whosestatusispendingand whosenextAttemptAthas passed. - Mark it
sending, then call the server with the entry'sidin anIdempotency-Keyheader and a fresh auth token. - On
2xx: in one transaction, delete the entry and update the local record (syncState: "synced", server fields). Go to step 2. - On a temporary failure: set
statusback topending, raiseattempts, setnextAttemptAtwith backoff, and stop the loop. - On a permanent failure: set
statustofailed, keeplastError, 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.
// 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 }));
});
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
waitUntilpromise 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.lastChanceistrueon 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
onlineevent. It fires when the browser's own view flips from offline to online. Treat it as a hint:navigator.onLine === trueonly means "some network interface is up", not "your server is reachable". - App start and
visibilitychangeto 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
onlineevent fired. - A backoff timer. When an entry is waiting to retry, schedule a
setTimeoutfor itsnextAttemptAtwhile the page is open.
Decision Who sends the queued writes?
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.
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",
);
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.
The technician taps Submit. The report and an outbox entry are saved in one transaction. The entry gets the key k-7f3c now, once.
Two ways to make a create safe to repeat
- Client-generated ids. The client makes the id (
crypto.randomUUID()) and sendsPUT /reports/7f3c...or aPOSTwhose 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 normalPOST. 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.
- New key: record it as
in progress, run the operation, store the response, and mark itdone, ideally in the same database transaction as the operation itself. - Known key, done, same body: do not run anything. Return the stored response, the same
201with the same report id. The client cannot tell it apart from the first answer, which is the point. - Known key, still in progress: a second copy arrived while the first is still running. Answer
409 Conflictso the client retries later instead of running it twice in parallel. - Known key, different body: a client bug reused a key. Answer
422and 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.
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"}
Think first
The user double-clicks Submit while online. Does the idempotency key help?
Only if both clicks produce the same key, and normally they do not: each click is a separate user action that creates its own entry with its own key. The server will happily create two reports.
Double clicks are a UI problem: disable the button while the form is being saved, or make the second click a no-op if an identical pending entry exists. Idempotency keys protect against retries of one action, not against two actions.
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.alllets 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 idtmp-1, which means nothing to the server.
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.
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.
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
| Result | Meaning | What the engine does |
|---|---|---|
| Network error, timeout | Never reached the server, or the answer was lost | Keep the entry, back off, stop the loop. The key makes the retry safe. |
408, 429, 502, 503, 504 | Server busy or briefly down | Back off, respecting Retry-After if present. Stop the loop. |
401 | Token expired while the entry waited | Refresh the token once and retry now. If refresh fails, pause the queue and ask the user to sign in. |
409 on the key | Same key still being processed | Retry after a short delay. |
409 or 412 on the data | Someone else changed the record | Hand it to conflict resolution: merge, rebase, or ask the user. |
400, 403, 404, 422 | The write itself is invalid or not allowed | Mark failed, keep the error, block its group, tell the user. |
2xx | Applied, maybe on an earlier attempt | Delete the entry, apply the server's response locally. |
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
500that 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 theonlineevent 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.
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
fetchfrom 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.userIdagainst 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
schemaVersionon 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
| State | What to show | Where |
|---|---|---|
| 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 |
| Sending | A subtle spinner on the item. No toast. | On the item |
| Synced | Remove 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 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
attemptsandnextAttemptAt, sets the entry back topending, 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
buttonelements 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
POSTaccepts anIdempotency-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: theBackgroundSyncPluginstores failed requests in IndexedDB and replays them on thesyncevent, 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.
- The UI writing to a local store and an outbox in one transaction.
- A sync engine reading the outbox, with its triggers: enqueue,
online, app start, Background Sync where supported. - The request carrying an
Idempotency-Key, and a key store on the server next to the database. - The entry state machine: pending, sending, done, waiting to retry, failed.
- The UI states: pending badge, header count, failed item with Retry and Discard.
| Level | What a strong answer covers |
|---|---|
| Junior | Queue writes in IndexedDB instead of dropping them; retry when back online; show a pending state. |
| Mid | Idempotency keys created at enqueue time; Background Sync with feature detection and page fallbacks; FIFO sending; delete only after 2xx; Retry and Discard for failures. |
| Senior | At-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?
On submit, write the change to local state and append an entry to an IndexedDB outbox in one transaction, with a UUID created right then. Show the item as saved on this device.
A sync engine sends outbox entries in order with the UUID as an Idempotency-Key, deletes each only on a 2xx, and backs off on network errors and 5xx. It runs on enqueue, on the online event, on app start, and on a Background Sync sync event in Chromium so it works after the tab closes.
Permanent errors mark the entry failed and show Retry, Edit, and Discard on the item.
Question 2
The request timed out but the server may have saved it. How do you retry safely?
The client cannot tell "never arrived" from "response lost", so it must retry, which means delivery is at least once. Exactly-once effect comes from the server: it stores each idempotency key with its response, scoped to the user, and replays the stored response for a repeat key instead of running the operation again.
The key must be created when the user acts and stored with the entry, so every retry sends the same one. The server keeps keys longer than the longest an entry can wait in the queue.
Question 3
What does the Background Sync API give you, and what can't you rely on it for?
It lets a page register a tag; the browser fires a sync event in the service worker when it has connectivity, even if all tabs are closed. If the waitUntil promise rejects, Chrome tries again, three attempts in all by default, about 5 and then 15 minutes apart, with lastChance set on the final one.
It is Chromium-only, retries are minutes apart and limited, and Chrome stops each event after 3 minutes. So it is a bonus trigger; the outbox, page-side triggers on online and app start, and idempotency carry the design. Periodic Background Sync is a different, much more limited API for refreshing content, not for writes.
Question 4
A user creates a list offline, adds items, then renames it. How do you keep those writes consistent?
Send writes for one entity strictly in order, one at a time, and allow parallelism only across unrelated entities. A failed entry blocks only its own group.
Prefer client-generated ids so items can reference the list before the server has seen it. If the server must assign ids, keep a temp id map and, when the create returns, rewrite the local record and every pending entry that references the temp id in one transaction. Coalesce pending updates to the same record to send less.
Question 5
What can go wrong with writes that wait in a queue for hours?
The access token expires: attach a fresh token at send time, refresh once on 401, and pause the queue if sign-in is needed. The user may sign out or switch: check the entry's user before sending. The idempotency key may expire on the server: cap queue age below the key window.
The app may update and change payload shapes: version entries and migrate them. Storage can be evicted: request persistent storage. A poison entry can block the queue: cap attempts and age, move it aside as failed, and keep it for the user to decide.
Practice it
These design problems lean on background sync and queued writes.
References
- Background Synchronization API (MDN) SyncManager, the sync event, and browser support.
- Web Periodic Background Synchronization API (MDN) Why periodic sync is a different tool.
- workbox-background-sync (Chrome for Developers) The BackgroundSyncPlugin and its Queue class.
- Idempotent requests (Stripe API reference) A widely copied idempotency key design.
- The Idempotency-Key HTTP Header Field (IETF draft) The proposed standard for the header and its error codes.
- Web Locks API (MDN) One flusher across tabs and workers.
- Storage quotas and eviction criteria (MDN) When queued data can disappear, and persistent storage.
Read next
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Progressive web appsA website that the browser can install as an app: a manifest gives it a name, icons, and its own window, a service worker makes it open without the network, and the result lives on the home screen without an app store.