Offline-First With Service Workers: Cache Strategies and the Pitfalls Nobody Mentions
Service worker cache strategies, IndexedDB persistence, and the pitfalls nobody mentions: version skew, storage eviction, opaque responses, and update UX.
"Works offline" is three words in a feature list and a minefield of caching bugs in practice. Service workers give web apps a programmable network proxy — the foundation of offline capability — but the API's power comes with failure modes that only show up after deployment. Here's the architecture that works for browser-native tools, the cache strategies worth knowing, and the pitfalls that will bite you anyway.
What a Service Worker Actually Is
A service worker is a JavaScript file the browser runs in a separate thread, sitting between your page and the network. Every request your page makes can be intercepted in the worker's fetch event and answered from cache, from the network, or both. It persists after the tab closes, which is what makes "load the app with no connection" possible at all. Registration is a one-liner — navigator.serviceWorker.register('/sw.js') — and everything after that one-liner is where the engineering lives.
The Three Cache Strategies Worth Knowing
- Cache-first: serve from cache; only hit the network on a miss. Correct for immutable assets — versioned JS/CSS bundles, fonts, images with content hashes in the URL. Fast, but catastrophic for anything that changes: a cache-first
index.htmlmeans users run your app from six months ago. - Network-first: try the network, fall back to cache on failure. Correct for data and API responses where freshness matters and the cache exists purely as an offline parachute.
- Stale-while-revalidate: serve the cached copy immediately and fetch a fresh copy in the background to update the cache for next time. The sweet spot for application shells: instant load, self-healing staleness, works offline. The cost: users can be one version behind for exactly one load.
A sensible default architecture: cache-first for fingerprinted static assets, stale-while-revalidate for the HTML shell, network-first for anything resembling live data.
IndexedDB: The Other Half
Caching gets you an app that loads offline; IndexedDB gives you an app that's useful offline. It's the browser's transactional, indexed object store — the right home for user-created state, saved analyses, and queued writes. localStorage is not a substitute: it's synchronous (blocks the main thread), capped around 5MB, and string-only. IndexedDB handles hundreds of megabytes of structured data with real indexes. The API is callback-era hostile, so use a thin promise wrapper (idb is ~1KB) rather than raw event handlers.
Pitfall 1: Cache Versioning Done Wrong
The standard pattern: name your caches with a version (app-v3), and in the worker's activate event delete every cache that isn't the current version. Forget the cleanup and old caches accumulate until the browser evicts you. Worse is the opposite bug: bumping the version on every deploy invalidates everything, so returning users re-download your entire bundle weekly. Version the cache by content that actually changed, not by release calendar.
Pitfall 2: The Update Lifecycle Trap
When you deploy a new service worker, it installs but does not take control — the old worker keeps serving until every tab of your site closes. Users who never close tabs (everyone) can run stale code indefinitely while your bug fix sits deployed and inert. The fix: listen for the waiting worker, prompt the user ("a new version is ready — reload?"), and call skipWaiting() on acceptance. Silently calling skipWaiting() plus clients.claim() without a prompt risks version skew mid-session — the page loaded HTML from v2 but now gets v3 assets. The prompt is not optional polish; it's correctness.
Pitfall 3: Storage Eviction and Quotas
Offline storage is not guaranteed. Browsers evict IndexedDB and Cache API data under storage pressure, starting with least-recently-used origins. Chrome's default quota is a fraction of disk (historically ~60% of free space per origin, far less on mobile), and Safari is aggressively stingy. Two mitigations: request persistent storage via navigator.storage.persist() for apps where offline data is the product, and always treat cached state as a cache — the user's source of truth should be exportable files, not your origin's storage.
Pitfall 4: Opaque Responses and POST Amnesia
Caching cross-origin CDN responses without CORS yields "opaque" responses you can serve but never inspect — and each one silently counts ~7MB against quota regardless of true size. And the Cache API only caches GET requests: POST bodies are not cacheable, so "queue the form submission for when we're back online" requires IndexedDB plus a background-sync retry loop, not a cache entry.
The Honest Summary
Offline-first is a compounding feature: every layer (shell caching, asset strategy, local persistence, update UX) is individually simple and jointly subtle. Budget the subtlety. But the payoff is real — an app that loads in 200ms on a train, works on a plane, and treats the network as an enhancement rather than a dependency. For tools that process data locally anyway, it's the natural completion of the architecture.