Service Worker Caching Strategies
Implementing deterministic offline state persistence requires precise orchestration of the CacheStorage API, fetch interception, and service worker lifecycle management. For frontend engineers and PWA developers building offline-first architectures, the caching strategy you pick directly shapes perceived performance, data consistency, and cross-browser reliability. This guide details production-ready patterns for service worker caching, emphasizing API precision, quota management, and fallback routing as part of the wider Offline Sync Strategies & Background Workflows approach. The single most important decision — when to serve from cache first and when to hit the network first — gets its own deep dive in Stale-While-Revalidate vs Network-First.
Choosing a strategy
The CacheStorage API gives you a key-value store of Request/Response pairs, but it imposes no policy — the policy is the order in which you consult the cache and the network inside the fetch handler. The four canonical strategies below cover almost every asset class, and the full comparison of the two most common ones is in Stale-While-Revalidate vs Network-First.
| Strategy | First byte from | Freshness | Works offline | Best for |
|---|---|---|---|---|
| Cache-first | Cache | Stale until purged | Yes | Hashed static assets, fonts |
| Network-first | Network | Always fresh when online | Falls back to cache | Auth-sensitive JSON, dashboards |
| Stale-while-revalidate | Cache | One revision behind | Yes | App shell, tolerant API reads |
| Network-only | Network | Always fresh | No | Analytics, non-cacheable POSTs |
Cache-first is fastest but serves whatever is stored until you explicitly invalidate it, so it suits content-addressed assets whose URL changes on every deploy. Network-first guarantees freshness when online and degrades to the cache offline, which fits data the user must not see stale. Stale-while-revalidate (SWR) splits the difference: it answers instantly from cache and refreshes the entry in the background, accepting that the user sees data exactly one revision old. Pick per route, not per app.
1. Setup & cache initialization
Deterministic cache invalidation begins with a strict namespace and versioning schema. Avoid generic keys like app-cache; use semantic prefixes that reflect asset type and deployment iteration (for example sw-static-v3.2.1, sw-api-v1). This guarantees that stale assets are isolated and safely discarded during activation.
// sw.js
const CACHE_VERSION = 'v3.2.1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/icons/favicon.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(ASSETS))
);
});
// Activate: delete stale cache versions and claim open clients immediately.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== STATIC_CACHE)
.map((name) => caches.delete(name))
)
).then(() => self.clients.claim())
);
});
Register the worker with explicit scope boundaries to prevent unintended route interception. Use navigator.serviceWorker.register('/sw.js', { scope: '/' }) for root-level control, or restrict to /app/ for modular deployments. During install, cache.addAll() executes atomically: if a single asset fails to fetch, the entire cache creation fails, preventing partial hydration states.
Aligning initial asset pre-caching with the broader Offline Sync Strategies & Background Workflows approach ensures deterministic state hydration across navigation cycles. This matters most when transitioning from a cached shell to dynamic data fetching on mobile networks, where connection instability is frequent.
2. Implementation: stale-while-revalidate with async fallback
The SWR pattern delivers instant cached responses while asynchronously updating the cache in the background. It is ideal for static assets, API endpoints with eventual consistency, and content that tolerates minor staleness.
self.addEventListener('fetch', (event) => {
// Bypass non-GET requests (POST, PUT, DELETE).
if (event.request.method !== 'GET') return;
event.respondWith(
(async () => {
const cache = await caches.open(STATIC_CACHE);
const cachedResponse = await cache.match(event.request);
// Parallel network fetch for background revalidation.
const networkFetch = fetch(event.request)
.then(async (res) => {
if (res.ok) {
// Clone required: a response body is a single-use stream.
await cache.put(event.request, res.clone());
}
return res;
})
.catch(() => null); // Graceful degradation on network failure.
// Return cached immediately; fall back to network on cache miss.
const response = cachedResponse || (await networkFetch);
// Static offline fallback if both cache and network fail.
if (!response) {
return new Response('Offline fallback content', {
headers: { 'Content-Type': 'text/html' },
});
}
return response;
})()
);
});
Implementation notes:
event.respondWith()must receive a promise resolving to aResponse. Wrapping the handler in an async IIFE prevents unhandled rejections.res.clone()is mandatory beforecache.put(). The Fetch API streams response bodies; consuming the body once for caching invalidates the stream for the client.- Defer non-critical cache updates through Background Sync API Implementation to avoid main-thread blocking during connectivity drops.
- Cross-browser compatibility: Chrome and Edge fully support async
respondWith(). Safari requires strict promise resolution within the event loop; avoid top-levelawaitoutside therespondWithwrapper.
3. Edge cases & storage boundaries
CacheStorage runs under strict browser-imposed quotas, sharing the origin’s storage pool with IndexedDB. Unbounded caching triggers QuotaExceededError during cache.put(), silently dropping updates or crashing the worker if unhandled.
Proactive quota management:
async function handleQuotaExceeded(request, response) {
const cache = await caches.open(STATIC_CACHE);
const keys = await cache.keys();
// Simple LRU-style eviction: remove the oldest entries until space is freed.
for (const key of keys.slice(0, 3)) {
await cache.delete(key);
}
// Retry the put after eviction.
try {
await cache.put(request, response);
} catch (err) {
console.error('Cache put failed even after eviction:', err);
}
}
Race condition mitigation. Concurrent navigation or rapid tab refreshes can trigger duplicate fetch interceptions. Use a request-deduplication map keyed by event.request.url to short-circuit redundant network calls and prevent simultaneous writes to the same cache key.
HTTP headers vs CacheStorage. Unlike the HTTP cache, CacheStorage ignores Cache-Control, Expires, and ETag headers. The worker must validate freshness manually using custom metadata or a timestamp header. When merging cached state with incoming network deltas — common with optimistic writes — apply deterministic Conflict Resolution Algorithms to prevent corruption, and coordinate the user-facing side of those writes with Optimistic UI Updates & Rollback.
4. Browser compatibility
Service workers and CacheStorage are broadly supported, but the behavioral edges differ enough to matter in production.
| Capability | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service workers + CacheStorage | Yes | Yes | 11.1+ | Yes |
Async respondWith() body |
Yes | Yes | Yes (strict event-loop timing) | Yes |
self.skipWaiting() / clients.claim() |
Yes | Yes | Yes | Yes |
navigator.storage.estimate() |
Yes | Yes | Partial / unreliable | Yes |
| SW persistence under ITP | n/a | n/a | Cleared after 7 days inactivity | n/a |
On iOS Safari 16 and 17, the CacheStorage pool is subject to Intelligent Tracking Prevention: a PWA the user has not installed to the Home Screen can have its caches wiped after roughly seven days of inactivity. Never treat the cache as durable on iOS; design the app to re-hydrate from the network on the next launch. Safari also reports unreliable numbers from navigator.storage.estimate(), so prefer reactive eviction on QuotaExceededError over proactive quota math there.
5. Debugging & production telemetry
Production service workers need observable telemetry to diagnose cache drift, fallback triggers, and activation failures.
- DevTools inspection. Navigate to Application > Storage > Cache Storage in Chromium DevTools and verify version alignment across deployments. Safari exposes the equivalent under Web Inspector > Storage > Cache Storage, with limited visibility into opaque responses.
- Custom telemetry hooks. Instrument cache-miss and fallback events with
performance.mark()andnavigator.sendBeacon(). Logevent.request.urlandresponse.statusto identify high-failure endpoints. - Network simulation. Use DevTools network throttling (
Fast 3G,Offline) to validate async fallback rendering. Verify thatcachedResponseresolves within roughly 50 ms and that background revalidation does not block UI hydration. - Lifecycle auditing. Monitor
installing → waiting → activatedtransitions. Callself.skipWaiting()ininstallandself.clients.claim()inactivateto force immediate takeover, but only after confirming no active client is mid-transaction. Useself.registration.update()to trigger background checks without user interaction.
By enforcing strict versioning, handling quota boundaries gracefully, and instrumenting lifecycle events, teams ship resilient service worker caching that scales across modern browsers and unstable networks.
Frequently Asked Questions
When should I use stale-while-revalidate instead of network-first?
Use stale-while-revalidate when an instant response matters more than perfect freshness and the user can tolerate seeing data one revision old — app shells, avatars, tolerant API reads. Use network-first when stale data is unacceptable, such as account balances or anything authorization-sensitive. The full decision is laid out in Stale-While-Revalidate vs Network-First.
Why must I clone the response before caching it?
A Fetch Response body is a single-use stream. If you pass the original response to cache.put(), its body is consumed and the client receives an empty stream, and vice versa. Calling res.clone() before caching gives you two independent readable bodies — one for the cache and one for the page.
Does CacheStorage respect Cache-Control headers?
No. Unlike the browser HTTP cache, CacheStorage stores exactly what you put in it and ignores Cache-Control, Expires, and ETag. You are responsible for expiry: stamp entries with a timestamp in custom metadata or a header and validate freshness yourself in the fetch handler.
How do I avoid QuotaExceededError when the cache grows?
Wrap cache.put() in a try/catch and, on QuotaExceededError, evict the oldest entries before retrying. CacheStorage shares the origin quota with IndexedDB, so an aggressive cache can starve your sync data. Pair this with Conflict Resolution Algorithms so evicted-then-refetched data still merges cleanly.
Related
- Stale-While-Revalidate vs Network-First — choosing the right strategy per route, with trade-offs.
- Background Sync API Implementation — deferring cache updates and mutations until connectivity returns.
- Conflict Resolution Algorithms — merging cached state with network deltas without corruption.
- Optimistic UI Updates & Rollback — reflecting cached writes in the UI and undoing rejected ones.
- Offline Sync Strategies & Background Workflows — the parent guide tying caching and sync together.