Offline-first is a data problem, not a caching problem
"Works offline" describes two very different capabilities, and most of the guidance covers only the easy one. An app that opens offline needs a cache. An app that works offline - that lets someone act, and carries those actions to the server later - needs a data model for writes that haven't happened yet. The service worker gives you the first. It has no opinion on the second.
The problem: reads are solved, writes aren't
The service worker is very good at one thing: intercepting requests and answering them from a cache. That covers the application shell, the catalogue someone browsed ten minutes ago, the images on the product they were looking at. Well-configured, it makes the app open instantly on a train and show the last thing the user saw.
Then the user taps "add to cart" and there is no network.
Nothing in the caching layer helps here. The request can't be answered from a cache because it isn't a read; it's a change. It can be queued - but now the app holds state the server has never seen, and every question that follows is a data question:
- What does the UI show while the change is pending?
- In what order do queued changes replay, and what if one fails?
- What happens if the same change is sent twice?
- What if the server's version moved while the client was away - the item sold out, the price changed?
- Which actions are safe to accept offline at all?
None of these are answered by caches.match(). They are answered by a
write-side design, and it is the part that decides whether "offline-first"
is a marketing word or a property of the system.
The read side, briefly
Reads deserve a tiered policy rather than one strategy, because different data has different tolerance for staleness. For a commerce app:
The shell is cache-first and versioned by build. The catalogue is stale-while-revalidate with a bounded age, and the UI is told when it's showing stale data. Prices, stock and anything in checkout are network-only: a cached price presented as current is a promise the app can't keep.
That's the read side. It's mostly configuration, and it's where most offline guidance stops.
The options for writes
| Approach | UX while offline | Complexity | Conflict surface | Where it fits |
|---|---|---|---|---|
| Block writes | Actions disabled with an explanation | Low | None | High-stakes actions; the honest default |
| Outbox queue | Optimistic; pending state shown | Medium | Per action, on replay | Low-conflict, user-owned actions |
| Local-first replicated store | Full function; data lives on the device | High | Continuous; needs merge semantics | Long offline windows; field & logistics |
Blocking is underrated. For an action whose outcome depends on the server - checkout, payment, anything touching stock - accepting it offline means telling the user something happened that hasn't, and might not. A disabled button with "you're offline - checkout needs a connection" is not a failure of offline support. It's the correct answer.
The outbox is the workhorse for everything else. A local-first store with real merge semantics is a different product category, and worth it only when the offline window is hours rather than minutes.
The pattern: a tiered write policy with an outbox
1. Classify every mutation by what it depends on
Before any queue exists, each write gets one of two labels:
- Self-contained - its correctness depends only on the user's intent. Adding to a cart, saving a favourite, drafting a review, changing a notification preference. The server can accept it whenever it arrives.
- Server-dependent - its correctness depends on state the client can't see. Placing an order, paying, reserving stock, applying a voucher. The server must decide, now, with current data.
Self-contained mutations go to the outbox. Server-dependent ones are blocked offline, with an explanation and a retry when the network returns. The classification is the whole policy; everything after is mechanics.
2. The outbox
Four properties make an outbox trustworthy rather than merely present:
Every entry has a client-generated idempotency key. A UUID minted when the user acts, sent with the request, stored by the server against the result. A replay after a timeout - the request that succeeded but whose response was lost - returns the stored result instead of applying the change twice. Without this, "retry" and "duplicate" are the same word.
Entries replay in order, per entity. Two edits to the same cart line must arrive in the order they were made; edits to different lines needn't wait for each other. A per-entity sequence number gives ordering where it matters and parallelism where it doesn't.
Failures are classified. A network error means retry with backoff. A 4xx
means the server rejected the intent - the item no longer exists, the user is
signed out - and the entry should be dropped and surfaced, not retried
forever. A 409 or 412 means a conflict, which is the next section.
Pending is a visible state. The local store carries a flag; the UI renders it. A cart line that hasn't reached the server looks slightly different from one that has, and the user can tell. Optimistic UI that hides its optimism is lying, and the lie is discovered at the worst moment.
3. Conflicts, by data type
A conflict is the server's state having moved while the client was away. The resolution rule depends on who owns the data, and it should be decided per entity, in advance:
| Data | Rule | Mechanism |
|---|---|---|
| Price, stock, availability | Server wins, always. The client's copy was never authoritative | Client refreshes and re-presents |
| User-owned settings, favourites | Last write wins, with a version check | If-Match / version column → 412 on mismatch |
| Cart | Merge: union of lines, server quantities where both changed | Server-side merge, client re-reads |
| Anything with money | Reject and ask. Never auto-resolve | Surface to the user; require a fresh action |
The trap is a single global rule. "Last write wins" applied to stock lets a stale client un-sell an item; "server wins" applied to a favourites list throws away a user's action for no reason. The table is short because the categories are few - but it has to exist.
4. Reconnection is an event, not a condition
When the network returns, the app should do three things in order: replay the outbox, refresh the data whose staleness matters (prices, stock, order status), and only then clear the "offline" indicator. Doing them in the other order shows the user "online" while their cart is still catching up.
Background Sync - the API that lets a service worker replay a queue even after the tab is closed - is the right tool where it exists, and it exists in Chromium-based browsers only. Treat it as an enhancement over an in-app replay on reconnect, not as the mechanism.
The trade-offs
The UI can show state the server never accepted. That is the definition of optimism, and it means the app can be wrong for as long as the queue is unsent. The pending indicator and the classification of what's allowed offline are the controls; they reduce the window, they don't remove it.
Replay can be surprising. A cart edit made on a plane applies when the plane lands - possibly hours later, possibly after the user has forgotten it. Bounding queue age (drop and notify after N hours) is worth more than it looks.
Storage is finite and evictable. Browsers can clear an origin's storage under pressure. The outbox must be treated as durable-ish, not durable: nothing irreplaceable lives only there, and the user is told if it was lost.
It's more to test. Offline paths are the ones nobody exercises by hand.
The outbox, ordering and conflict rules need tests that simulate a drop
mid-replay, a duplicate delivery, and a 412 - or they will fail in
production first.
When it flips
- Long offline windows - field service, delivery drivers, inventory counts in a warehouse with no signal. Hours of offline work with many related writes outgrows an outbox; this is where a local-first store with merge semantics (CRDTs or a sync engine) pays for its complexity.
- Pure content apps with no meaningful writes need only the read tiers. Building an outbox for "mark as read" is over-engineering.
- Regulated writes - anything where the timestamp of the action is legally significant - may not be allowed to be deferred at all. Block, and say why.
Checklist
- Have we split "opens offline" from "works offline" in the requirements, and costed them separately?
- Do reads have a tiered policy, and is anything money depends on excluded from the cache?
- Is every mutation classified as self-contained or server-dependent?
- Does every outbox entry carry a client-generated idempotency key the server honours?
- Is replay ordered per entity, and are failures classified rather than retried blindly?
- Can a user see which of their changes are pending?
- Is there a written conflict rule per data type - and is "reject and ask" the rule wherever money is involved?
- Is queue age bounded, and does the app tell the user if queued work was dropped?
The service worker is the easy half, and it is genuinely useful. The other half is deciding, per write, what the app is allowed to promise without the server in the room - and building the queue that keeps the promise.