Service Worker Caching Strategies
Without a service worker sitting between your pages and the network, an offline-first PWA has no way to answer a request when the radio is off: the fetch fails, the shell never paints, and the user stares at the browser’s dinosaur. The fix is a worker that intercepts every fetch event and decides, per request, whether to answer from the CacheStorage API or the network. Get that decision wrong and you ship one of two failure modes — a cache-first app that serves week-old JSON to a logged-in user, or a network-first app that hangs for eight seconds on a flaky train connection before falling back. This guide details production-ready patterns for service worker caching — API precision, quota-safe eviction, lifecycle takeover, and fallback routing — for the frontend engineers and mobile web teams who own that reliability.
This page sits under the wider Offline Sync Strategies & Background Workflows guide; for the storage primitives the cache shares an origin quota with, review Browser Storage Fundamentals & Quotas. 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.
The CacheStorage API surface
CacheStorage (exposed as the global caches) is a promise-based, asynchronous key-value store of Request → Response pairs, separate from the browser’s HTTP cache and available in both the window and the service worker global scope. Every method returns a promise, so all access happens off the main thread. These are the methods a caching strategy actually leans on:
| Method | Signature | Returns | Notes |
|---|---|---|---|
caches.open |
open(cacheName: string) |
Promise<Cache> |
Creates the named cache if it does not exist; the name is your version namespace. |
caches.keys |
keys() |
Promise<string[]> |
Lists all cache names for the origin — the basis for activation cleanup. |
caches.delete |
delete(cacheName: string) |
Promise<boolean> |
Drops a whole named cache; false if it was absent. |
cache.match |
match(request, options?) |
Promise<Response | undefined> |
Resolves undefined on a miss (it does not reject). options.ignoreSearch ignores query strings. |
cache.put |
put(request, response) |
Promise<void> |
Stores an entry. Rejects with QuotaExceededError when the origin pool is full. |
cache.addAll |
addAll(requests: RequestInfo[]) |
Promise<void> |
Atomic fetch-and-store; rejects the whole batch if any request is non-ok. |
cache.delete |
delete(request, options?) |
Promise<boolean> |
Removes one entry keyed by request URL. |
Three properties of this surface drive every pattern below. cache.match never throws on a miss — it resolves undefined, so a strategy is just a fallback chain of promises. cache.put accepts only same-origin or CORS responses with a readable body, which is why a response must be cloned before it is stored. And CacheStorage imposes no expiry policy of its own: it stores exactly what you hand it until you delete the entry.
Choosing a strategy
The 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.
Implementation walkthrough: 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. The narrower discipline of versioning and serving hashed static files this way is covered in depth in Cache API for Static Assets.
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.
Implementation walkthrough: stale-while-revalidate
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.
Concurrency & lifecycle considerations
A service worker is not a page script — it has its own installing → waiting → activated lifecycle, it can be killed and respawned between events, and it may be handling fetch events from several tabs at once. Three timing rules keep caching correct under that model.
Event-loop timing inside respondWith. event.respondWith() must be called synchronously within the fetch handler — before the handler yields to the microtask queue. That is why the pattern above wraps the async logic in an IIFE and passes the resulting promise to respondWith() in the same tick, rather than await-ing first. Safari is strict here: an await that runs before respondWith() detaches the response from the event and the request falls through to the network. The same rule applies to event.waitUntil() in install and activate — call it synchronously and hand it a promise.
Lifecycle takeover. A freshly installed worker sits in waiting until every tab controlled by the old worker closes. Call self.skipWaiting() in install to activate immediately and self.clients.claim() in activate to take control of open pages without a reload. Do this only after confirming no client is mid-transaction — forcing a new cache version while a page is halfway through writing optimistic state can strand that write. Coordinate the user-facing side of in-flight mutations with Optimistic UI Updates & Rollback.
Cross-tab request deduplication. Concurrent navigation or rapid refreshes across tabs can trigger duplicate fetch interceptions for the same URL, producing redundant network calls and simultaneous cache.put() writes to one key. Deduplicate with an in-flight map so a single network request fans out to every waiting event:
const inFlight = new Map();
function dedupedFetch(request) {
const key = request.url;
if (inFlight.has(key)) return inFlight.get(key);
const promise = fetch(request).finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
For coordination that must survive worker restarts or span windows the worker does not control, escalate to the Web Locks API for Cross-Tab Coordination, which serializes critical sections across the whole origin.
Freshness is your job, not the cache’s. Unlike the HTTP cache, CacheStorage ignores Cache-Control, Expires, and ETag headers — it stores exactly what you put in it. Validate freshness manually by stamping entries with a timestamp header and checking it in the fetch handler. When merging cached state with incoming network deltas — common with optimistic writes — apply deterministic Conflict Resolution Algorithms so evicted-then-refetched data still reconciles cleanly.
Error handling & retry: quota 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. The origin-level quota mechanics and eviction ordering are covered in Storage Quotas & Eviction Policies; at the worker level the defense is a bounded put with an eviction-then-retry fallback.
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);
}
}
// Wire the guard into any write path.
async function safePut(request, response) {
const cache = await caches.open(STATIC_CACHE);
try {
await cache.put(request, response);
} catch (err) {
if (err.name === 'QuotaExceededError') {
await handleQuotaExceeded(request, response);
} else {
throw err;
}
}
}
Because the cache and your sync database draw from the same origin pool, an aggressive cache can starve mutation data queued for Background Sync API Implementation. Cap the runtime cache to a known entry count and let hashed static assets live in their own versioned cache that only activation clears.
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.
Performance & scale considerations
Caching is a latency optimization, so it must not introduce latency of its own. A few numbers and limits matter once the cache grows past a toy PWA.
- Cache-hit latency. A
cache.match()on a warm cache typically resolves in single-digit milliseconds; treat anything above ~50 ms as a signal that the cache is oversized or the match is scanning too many entries. Split one giant cache into per-type caches (static,api,images) so eachmatchsearches a smaller keyspace. - Precache batch size.
cache.addAll()fetches every URL in parallel duringinstall. A precache list of hundreds of assets can saturate the connection and time out on 3G, and becauseaddAllis atomic, one failure discards the whole batch. Keep the install manifest to the critical app shell (tens of files) and let runtime stale-while-revalidate populate the remaining assets lazily. - Bounded runtime caches. Runtime caches grow unbounded by default. Enforce an entry cap or max-age sweep on activation so the cache cannot monopolize the origin quota it shares with IndexedDB. Eviction is cheaper as a periodic sweep than as an emergency
QuotaExceededErrorrecovery on the request hot path. - Memory pressure. Storing large opaque responses (cross-origin images, video) inflates disk usage two to seven times because opaque bodies are padded for privacy. Cache those in a dedicated, tightly capped image cache and never precache them.
- Avoid main-thread blocking. All CacheStorage work already runs off the page’s main thread, but heavy synchronous work inside the worker (large JSON parsing, crypto) still blocks other
fetchevents it is serving. Defer non-critical writes and mutations to Background Sync API Implementation so a connectivity drop never stalls navigation.
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.