Stale-While-Revalidate vs Network-First
A service worker fetch handler has to answer one question on every request: do you serve the cached copy now and update it later, or do you wait for the network and fall back to the cache only if it fails? Those two answers are the stale-while-revalidate and network-first strategies, and picking the wrong one shows up as either stale prices on a checkout page or a spinner that hangs for eight seconds on a flaky train connection. This guide compares them with complete, runnable TypeScript handlers and a decision table. It is part of Service Worker Caching Strategies within Offline Sync Strategies & Background Workflows.
The two strategies, precisely
Both strategies sit inside the service worker’s fetch event listener and both read from and write to a named Cache API for Static Assets store. The difference is purely in ordering.
Stale-while-revalidate (SWR) answers from the cache the instant a match exists, then kicks off a network request whose only job is to overwrite the cache for next time. The user never waits on the network. The cost is that the response they see may be one revision behind.
Network-first tries the network first and only reads the cache if the network fails or exceeds a timeout. The user always gets the freshest data the connection can supply, at the cost of latency on slow links — which is why a sensible timeout is mandatory, not optional.
Stale-while-revalidate handler
The key detail is event.waitUntil: it keeps the service worker alive while the background refetch completes, even though the response has already been returned to the page.
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = 'v3';
const SWR_CACHE = `swr-${CACHE_VERSION}`;
async function staleWhileRevalidate(
event: FetchEvent,
): Promise<Response> {
const cache = await caches.open(SWR_CACHE);
const cached = await cache.match(event.request);
// Fire the network request regardless; its job is to refresh the cache.
const networkFetch = fetch(event.request)
.then((response) => {
// Only cache successful, same-origin (non-opaque) responses.
if (response.ok && response.type !== 'opaque') {
// Response bodies are one-shot streams: clone before caching.
cache.put(event.request, response.clone());
}
return response;
})
.catch(() => undefined);
if (cached) {
// Keep the worker alive so the background update finishes.
event.waitUntil(networkFetch);
return cached;
}
// Cold cache: there is nothing to serve yet, so wait on the network.
const network = await networkFetch;
if (network) return network;
return new Response('Offline and not cached', {
status: 504,
statusText: 'Gateway Timeout',
});
}
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.method !== 'GET') return;
event.respondWith(staleWhileRevalidate(event));
});
Network-first handler with a timeout
A network-first handler without a timeout is a trap: on a connection that accepts the socket but never sends bytes, fetch can hang indefinitely and the user stares at a blank screen even though a perfectly good cached copy exists. The fix is to race the fetch against an AbortController timer.
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = 'v3';
const NETWORK_CACHE = `pages-${CACHE_VERSION}`;
const NETWORK_TIMEOUT_MS = 3000;
async function fetchWithTimeout(
request: Request,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(request, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function networkFirst(request: Request): Promise<Response> {
const cache = await caches.open(NETWORK_CACHE);
try {
const fresh = await fetchWithTimeout(request, NETWORK_TIMEOUT_MS);
if (fresh.ok && fresh.type !== 'opaque') {
await cache.put(request, fresh.clone());
}
return fresh;
} catch {
// Network failed or timed out — serve the last good copy.
const cached = await cache.match(request);
if (cached) return cached;
return new Response('Offline and not cached', {
status: 504,
statusText: 'Gateway Timeout',
});
}
}
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.method !== 'GET') return;
event.respondWith(networkFirst(event.request));
});
Choosing between them
| Dimension | Stale-while-revalidate | Network-first |
|---|---|---|
| Freshness | Eventually fresh (one revision behind) | Always freshest the network allows |
| Latency | Instant (cache speed) | Network latency, capped by timeout |
| Offline behavior | Serves last cache silently | Serves cache only after timeout fails |
| First (cold) load | Must wait for network | Must wait for network |
| Best for | App shell, avatars, CSS/JS, feeds | HTML documents, prices, dashboards |
| Risk | Showing outdated content briefly | Slow render on flaky links |
Reach for SWR when a slightly stale answer is harmless and speed matters: navigation chrome, stylesheets, user avatars, and list views that refresh on the next visit anyway. Reach for network-first when correctness beats speed: account balances, inventory counts, and any document where a stale value misleads the user.
Verification with DevTools
You can confirm each strategy behaves correctly without writing a single assertion, using Chrome DevTools.
- Open DevTools → Application → Service Workers and confirm your worker is activated and running.
- Switch to the Network panel. For an SWR route, reload and look for two entries per resource on the second visit: the page renders from the
(ServiceWorker)-sized cache entry, while a separate background request hits the server. The first reload is served instantly; check the Size column reads(ServiceWorker). - For a network-first route, open Network → throttling and pick Slow 3G (or a custom profile slower than your timeout). The request should abort at your
NETWORK_TIMEOUT_MSand the response should still arrive — served from cache. - Tick Network → Offline, then reload. Both strategies should still render from cache; only the network-first console will show the caught fetch rejection.
- Inspect Application → Cache Storage and confirm a single versioned cache (
swr-v3,pages-v3) holds the expected entries and old versions are gone.
A programmatic smoke test from the page confirms the cache name and contents:
async function assertCacheState(): Promise<void> {
const names = await caches.keys();
console.assert(
names.includes('swr-v3') || names.includes('pages-v3'),
'expected a current versioned cache',
);
const stale = names.filter((n) => /-(v1|v2)$/.test(n));
console.assert(stale.length === 0, `stale caches left behind: ${stale}`);
}
Edge cases and fallbacks
Cache versioning and cleanup. Bump CACHE_VERSION whenever cached assets change shape, and delete every cache that is not on the current allowlist inside activate. Without this, old responses linger forever and quota fills up.
const CURRENT = new Set(['swr-v3', 'pages-v3']);
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names.filter((n) => !CURRENT.has(n)).map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
Opaque responses. A cross-origin request made with mode: 'no-cors' returns an opaque Response whose status reads 0 and whose body you cannot inspect. Both handlers above guard with response.type !== 'opaque' so you never cache a hidden error page. Opaque responses also count toward quota at a padded size, so caching them blindly inflates storage; weigh this against the durability patterns in When to Use the Cache API over IndexedDB.
When cache-first is the right answer instead. For immutable, content-hashed build artifacts (app.4f9a.js), neither SWR nor network-first is ideal — there is no point revalidating a file that can never change at that URL. Use cache-first: serve from cache, and only touch the network on a miss. This eliminates the SWR background request entirely. For request types that mutate server state, see Background Sync API Implementation rather than any read-cache strategy.
Frequently Asked Questions
Does stale-while-revalidate ever block on the network?
Only on a cold cache. When no cached match exists, the handler must await the network because there is nothing to serve. Once the first response is cached, every later request returns instantly while the refetch runs in the background under event.waitUntil.
What timeout should I set for network-first?
Between 2 and 4 seconds is typical. Shorter than your real server’s p95 response time and you fall back to cache too often; longer and slow connections feel broken. Measure your endpoint, set the timeout just above its p95, and verify under Slow 3G throttling in DevTools.
Why must I clone the response before caching it?
A Response body is a one-shot stream. Reading it for the page consumes it, so cache.put(request, response.clone()) stores a second readable copy. Omit the clone and either the page or the cache gets an already-drained body.
Related
- Service Worker Caching Strategies — the parent guide covering cache-first, SWR, network-first, and network-only.
- Cache API for Static Assets — the storage layer both strategies write to.
- When to Use the Cache API over IndexedDB — picking the right store for cached responses.
- Background Sync API Implementation — the right pattern for mutating requests, not read caching.
- Offline Sync Strategies & Background Workflows — the broader offline architecture these strategies fit into.