The whole book compresses to a single sentence: the network may drop on any line, the user's data may not. Take that concretely. The user taps "Create order", the request reaches the server, the server commits the order - and the response is lost on the way back. The client sees an error. How do you retry without creating a second order? Everything else in offline-first is scaffolding around that one question, and a good answer is assembled from several decisions in the right order.
The place to start is not "let's add a service worker" but value. For each scenario, name honestly what offline is worth: a courier who loses signal in a basement must still see the route and mark a stop - here offline is critical; a static marketing page gains almost nothing from it. The value statement decides how much machinery a given screen deserves.
Next, split everything the app touches into four kinds of data - each wants its own treatment. Assets (the app shell, JS, CSS) are versioned by hash and precached. Snapshots (a rendered list you have already fetched) are cacheable views that are safe to show stale. Entities (the order records themselves) live in IndexedDB as the local source of truth. Commands (create order, cancel) are user intents that must survive a reload and a dead network. Confusing a command with a snapshot is the root of most data loss.
For every request, make two decisions explicitly. Freshness - how stale an answer is acceptable: cache-first for the shell, network-first where you need a balance of freshness and availability, stale-while-revalidate for lists that tolerate a second of staleness in exchange for an instant paint. And failure semantics - what the user sees when the server is unreachable: a blank screen, stale data with a label, or an explicit error. It is precisely on vague defaults that offline apps rot; the policy is worth writing down per endpoint.
The safety rule for writes: a durable local transaction first, the optimistic UI second. The order lands in an IndexedDB outbox before the button animates; if the tab dies, the intent survives. And the lost response is closed on the server: each command gets its own idempotency key, so a retry of a request the server already processed returns the same result rather than a second order. Alongside it - versioning and a clear conflict-resolution UX for when two edits of one entity race each other.
Only now add Background Sync - strictly as an optimization on top of a foreground path that already works. Background Sync is a hint from the browser that connectivity is back, not a guarantee and not a universally available feature. If the outbox drains only on that event, writes strand forever on platforms without it. The floor is a foreground flush: on app open and on the online event; Background Sync on top is merely a bonus where it exists.
Design the update lifecycle and rollback before the first deploy, not after the first incident: a short-cached sw.js, immutable hashed assets, N and N-1 compatibility, a kill switch ready. And test the state transitions on real target devices, not in an emulator on fast Wi-Fi: airplane mode mid-write, a forced update with an old tab open, eviction by quota. The transitions - not the checkboxes in a list - are where offline-first is actually won or lost.