Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 6 of 12AdvancedAug 3, 2026

Offline and PWA Architecture

The service worker lifecycle, choosing a caching strategy per resource type, queuing and replaying offline mutations, and the app shell model.

frontend-system-designpwaoffline

Why It Matters

Offline capability is usually framed as a feature for users on trains and planes. That undersells it. The far more common case is the unreliable network - a weak mobile signal, a congested café connection, the two seconds while a phone hands off between cell towers. On those connections a normal web app does not degrade, it fails: a blank page, a hung spinner, a lost form submission.

An offline-capable architecture is really a resilience architecture. It happens to also work at 30,000 feet.

The Service Worker Lifecycle

A service worker is a script that runs separately from the page, has no DOM access, and can intercept network requests from the pages it controls. It is a programmable proxy that you deploy to the user's device - which makes it powerful and makes lifecycle bugs expensive.

Registration. The page registers the worker script. The browser downloads it and, on subsequent visits, byte-compares it to the installed version. Identical bytes mean nothing happens. Any difference starts an update.

Install. Fires once per version. This is where you precache the app shell. If anything in the install handler rejects, the install fails and the worker is discarded - so precaching a URL that 404s silently breaks your entire update.

Waiting. This is the step that surprises people. A newly installed worker does not take over. It waits, because the old worker still controls open pages, and swapping mid-session would leave one page running against two asset versions. The new worker activates only when every page controlled by the old one has closed - which for a pinned tab can be days.

Activate. The new worker takes control. This is where you delete old cache versions, since nothing is serving from them anymore.

Fetch. From now on, every network request from a controlled page passes through your fetch handler first.

Diagram
100%
flowchart TB R["Page registers worker"] --> BC{"Byte-compare<br/>with installed?"} BC -->|"identical"| Nothing["Nothing happens"] BC -->|"different"| I["INSTALL<br/>precache app shell"] I -->|"precache fails"| Discard["Worker discarded<br/>update silently lost"] I -->|"success"| W["WAITING<br/>old worker still controls pages"] W -->|"all old tabs closed"| A["ACTIVATE<br/>delete old cache versions"] W -->|"skipWaiting + clients.claim"| A A --> F["FETCH<br/>intercept every request"] F --> D{"Strategy by<br/>resource type"} D -->|"hashed assets"| CF["Cache-first"] D -->|"HTML, API"| NF["Network-first"] D -->|"avatars, non-critical"| SWR["Stale-while-revalidate"] style W fill:#3f2d1e,stroke:#f59e0b style Discard fill:#3f1e1e,stroke:#ef4444 style A fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Two escape hatches exist for the waiting phase. skipWaiting() activates immediately, and clients.claim() takes control of already-open pages. Together they make updates instant - and they are only safe if the new assets can be served to a page that was loaded against the old ones. If a lazy chunk URL changed, an open page will request a chunk that no longer exists. The safer pattern for most apps is to detect the waiting worker and prompt the user to reload.

Versioning the cache name is non-negotiable. Use cache-v3 rather than cache, delete the old names on activate, and you have a clean cutover. Reuse one name across deploys and you will serve a mix of old and new assets with no way to recover.

Caching Strategies by Resource Type

The fetch handler is a router. The strategy should be chosen per resource type, because the resource types have genuinely different correctness requirements.

Cache-first. Check the cache; go to network only on a miss. Fastest possible response and works offline, but the user gets the cached version even when a newer one exists.

Correct for hashed static assets. A content-hashed URL is immutable by construction - that exact URL will never have different content - so serving it from cache forever is always right. Also correct for fonts and versioned images.

Network-first. Try the network; fall back to cache on failure. Always fresh when connected, degrades gracefully when not, at the cost of waiting for a network timeout before falling back.

Correct for HTML documents and API responses where freshness matters. HTML is the mutable file that points at your hashed asset URLs - cache it first and users keep loading old bundles no matter what you deploy. This is the single most common service worker bug.

Stale-while-revalidate. Serve the cached version immediately and refetch in the background for next time. Instant response, one version behind.

Correct for content where being slightly stale is invisible and being slow is not: avatars, non-critical images, secondary content, a settings payload.

The routing table for a typical app:

RequestStrategyWhy
/static/*.[hash].jsCache-first, immutableURL cannot change content
/ and navigation requestsNetwork-firstMust reflect the current deploy
/api/user/*Network-firstCorrectness matters
/api/feedStale-while-revalidateInstant load beats perfect freshness
Avatars, thumbnailsStale-while-revalidateSlightly stale is invisible
Analytics beaconsNetwork-onlyNever cache, never replay from cache

Offline Mutations: Queue and Replay

Reads offline are the easy half. Writes are where the design gets hard, because a mutation made offline is a promise you have made to the user that you cannot yet keep.

The architecture is a durable outbox:

  1. The user acts. Update the local state optimistically so the UI reflects it immediately.
  2. Write the mutation to a persistent queue - IndexedDB, not memory, because the tab will close.
  3. On reconnect (a online event, or Background Sync where available), drain the queue.
  4. On success, remove the entry and reconcile with the server response.
  5. On failure, decide: retry with backoff, or surface it to the user.

Three properties have to hold, and each one is a place real implementations break.

Order. A create followed by an update must not replay in reverse. The queue must be FIFO and drain sequentially - firing the queue in parallel on reconnect is a tempting optimisation that produces exactly this bug.

Idempotency. You cannot distinguish a request that never reached the server from one that succeeded but whose response was lost. Both look like a failure, and both will be retried. Every queued mutation must carry a client-generated idempotency key that the server uses to deduplicate. Without it, a flaky reconnect creates duplicate orders.

Conflict resolution. The server may have changed while the client was offline. There is no universally correct policy, so it has to be chosen per entity:

  • Last-write-wins - simplest, and silently destroys the other write. Acceptable for a user preference, not for a shared document.
  • Server-wins - the offline change is rejected and the user is told. Safe, and frustrating if they did substantial work.
  • Merge - field-level or CRDT-based reconciliation. Correct for collaborative editing, and a large amount of machinery.
  • Prompt the user - show both versions and let them choose. Honest, and only tolerable for infrequent conflicts.

The trap is defaulting to last-write-wins because it requires no decision. That is a decision, and it is the one that loses data.

The App Shell Model

Split the application into two things with different lifetimes:

  • The shell - the HTML skeleton, CSS, and JavaScript that render the chrome: header, navigation, layout, loading states. It changes on deploy.
  • The content - everything inside it. It changes constantly.

Precache the shell at install; fetch content per navigation.

Diagram
100%
flowchart TB subgraph Install["Service worker INSTALL - once per version"] P["Precache shell:<br/>index.html skeleton<br/>app.css, app.js<br/>logo, icons, offline page"] end subgraph Repeat["Repeat visit"] N["User navigates"] --> SWH["SW serves shell from cache<br/>ZERO network"] SWH --> Paint["Chrome painted immediately"] Paint --> CF["Content fetched into<br/>existing layout"] CF --> Done["No layout shift -<br/>space already reserved"] end subgraph Offline["Repeat visit, offline"] N2["User navigates"] --> SWH2["SW serves shell from cache"] SWH2 --> Paint2["Chrome painted - app looks alive"] Paint2 --> Cached["Cached content, or<br/>a real offline state"] end P -.-> SWH P -.-> SWH2 style Install fill:#1e3a5f,stroke:#3b82f6 style Repeat fill:#1e3f2d,stroke:#22c55e style Offline fill:#3f2d1e,stroke:#f59e0b
visualized byIOCombats

What makes this fast is that the shell has zero network dependency. On a repeat visit the frame is on screen before any request leaves the device. And because the layout already exists when content arrives, there is nothing to shift - the model is good for CLS almost as a side effect.

The cost is that the initial HTML is content-free, which is bad for search indexing. That makes the app shell a good fit for authenticated application surfaces and a poor one for public content pages - which is why it pairs naturally with server rendering for the marketing side and app shell for the product.

When Not to Build This

A service worker adds a real update lifecycle, a class of stale-asset bugs that are hard to reproduce, and a debugging burden that persists for the life of the product. That cost is worth paying when users are genuinely on unreliable networks or need the app to work disconnected.

It is not worth paying when the content is dynamic and correctness beats availability - a trading screen, a live inventory count, an admin surface acting on current state. Serving stale data there is worse than showing an honest offline message. Nor is it worth paying for a mostly-static site already well served by CDN and HTTP caching, where the service worker adds risk in exchange for very little.

Tradeoffs

OptionProsConsWhen to Use
Cache-firstFastest possible response, fully offline capableServes stale content until the cache is versioned outContent-hashed assets, fonts, versioned images
Network-firstAlways fresh when online, graceful offline fallbackWaits for a network timeout before falling backHTML documents, correctness-sensitive API reads
Stale-while-revalidateInstant response and eventual freshnessAlways one version behindAvatars, thumbnails, secondary content
skipWaiting + clients.claimUpdates apply immediately, no stale workerOpen pages can request chunks that no longer existOnly when new assets are safe for already-loaded pages
Prompt-to-reload on updateSafe, user-controlled, no mixed-version stateUsers can dismiss it and stay stale for daysThe sane default for most applications
Offline mutation queueWrites survive disconnection, UI stays responsiveNeeds idempotency keys, ordering, and a conflict policy per entityField tools, note-taking, anything used on poor connections

Where This Applies

The service worker is the top layer of the caching stack described in Performance Engineering - its versioning has to agree with your CDN and HTTP cache headers or you will serve mixed versions. The optimistic update and reconciliation logic behind the mutation queue is the offline case of the server-state problem in State Management Architecture, and the shell-versus-content split is the same decision made for a different reason in Rendering Architecture.

In the applied practice problems, this drives Collaborative Document, where every write must survive disconnection and merge on return, and File Upload System, where resumable chunked uploads are the same queue-and-replay pattern applied to bytes. Push notifications, the other reason to run a Service Worker, are covered in Notification System.

Advertisement

Frequently Asked Questions

A user has an old service worker and you deploy a new one. Walk through what happens.

The browser fetches the service worker script on the next navigation and byte-compares it to the installed one. If it differs, the new worker installs and precaches its assets, then enters a waiting state - it does not activate, because the old worker is still controlling open pages and swapping mid-session would mean one page running against two asset versions. It activates only when every page controlled by the old worker has closed, which for a pinned tab can be days. You can shorten that with skipWaiting plus clients.claim, but only if the new assets are safe to serve to a page that was loaded against the old ones - otherwise you should prompt the user to reload instead.

Why is cache-first dangerous for HTML but correct for hashed JavaScript?

Because of what the URL guarantees. A hashed bundle URL is immutable by construction - that exact URL will never have different content - so serving it from cache forever is always correct. HTML is the opposite - it is the mutable document that points at those hashed URLs, so caching it first means a user keeps loading the old asset URLs no matter what you deploy. HTML needs network-first with a cache fallback, so a connected user always gets the current document and an offline user still gets a page.

How do you replay a queue of offline mutations without corrupting server state?

Three properties have to hold. Order, because a create followed by an update must not replay in reverse - the queue must be FIFO and drain sequentially, not in parallel. Idempotency, because you cannot distinguish a request that failed from one that succeeded but whose response was lost, so each queued mutation carries a client-generated idempotency key the server uses to deduplicate. And conflict handling, because the server state may have changed while the client was offline - which needs an explicit policy per entity, not a global default.

What actually makes the app shell model fast on repeat visits?

That the shell has zero network dependency. The HTML skeleton, CSS, and JavaScript that render the chrome are precached at service worker install, so on a repeat visit they are served from local storage with no round trip at all - the frame is on screen before any request goes out. Only the content inside the shell is fetched, and it fetches into a layout that already exists, so there is no layout shift when it lands. The trade is that the initial HTML is content-free, which is bad for SEO, so the model suits authenticated app surfaces rather than indexable pages.

When is a service worker the wrong tool?

When the content is genuinely dynamic and correctness beats availability - a trading screen, a live inventory count, an admin surface acting on current state. Serving stale data there is worse than showing an offline message. It is also wrong when the site is mostly static content already well served by CDN and HTTP caching, because a service worker adds a real update lifecycle, a whole class of stale-asset bugs, and a debugging burden in exchange for very little. The honest test is whether users are actually offline or on unreliable connections often enough to justify that cost.