Article

Offline-first is a data problem, not a caching problem

Kreshnik Rraci ·

"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:

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:

Three read tiers with different caching strategies and different offline behaviour App shell HTML · CSS · JS · icons cache-first, versioned by build offline: opens Catalogue & content stores · products · images stale-while-revalidate, bounded age offline: shows last seen, marked stale Price, stock, checkout the numbers money depends on network-only, never cached offline: unavailable, and says so the further down, the less staleness is tolerable - and the less the cache should promise
One caching strategy per tier. The bottom tier is deliberately not cached: a stale price shown as current is worse than no price.

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 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

The outbox flow from UI to local store to queue to server, with the acknowledgement reconciling back UI add to cart Local store applied · pending Outbox id · op · seq · key when online, in order Server dedupe by key ack → clear pending · apply the server's version of the result the UI reads the local store, so it reflects intent immediately and truth once the server answers
The outbox. The UI applies the change locally and marks it pending; the queue replays in order when a connection exists; the acknowledgement replaces the optimistic state with the real one.

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

Checklist

  1. Have we split "opens offline" from "works offline" in the requirements, and costed them separately?
  2. Do reads have a tiered policy, and is anything money depends on excluded from the cache?
  3. Is every mutation classified as self-contained or server-dependent?
  4. Does every outbox entry carry a client-generated idempotency key the server honours?
  5. Is replay ordered per entity, and are failures classified rather than retried blindly?
  6. Can a user see which of their changes are pending?
  7. Is there a written conflict rule per data type - and is "reject and ask" the rule wherever money is involved?
  8. 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.