Not everything in an orders app tolerates staleness. An order's status, a stock level, a just-assigned address - here the user needs the fresh version, and an old snapshot from the cache is useless at best. But the network on a weak link is treacherous: it does not always fail honestly, sometimes it simply hangs. The strategy must put freshness first and, at the same time, not make the person wait on a stalled response forever.
The answer to this is Network First. The order is the reverse of cache-first: the request goes to the network first, and on success its result is both returned to the user and stored in the cache as the last good snapshot. Only if the network fails does the cache step in - that last snapshot is served, and if even that is missing, the app synthesizes an explicit offline response, for example JSON with a 503 status and an offline flag, so the UI shows an honest placeholder instead of a blank screen.
The subtlety is in the word failed. A naive timeout via Promise.race simply stops waiting for the network, but the original fetch itself is not canceled and keeps hanging in the background. If the network really must be interrupted, you use an AbortController. But cancellation has a flip side: by aborting the request you lose the chance to update the cache with a late but successful response that would have arrived a second after the timeout. It is a deliberate trade-off - which matters more in a given case: showing the cache quickly, or waiting after all to save something fresh.
For page navigations Network First has a built-in accelerator - Navigation Preload. Normally the worker must wake up first, and only then does it initiate the fetch, and that wakeup adds latency. Navigation Preload starts the network request in parallel with the worker's wakeup, so by the time the handler is ready the response is already on its way. For a slow start this is a noticeable saving.
There is also an in-between strategy that dissolves the conflict between speed and freshness - Stale While Revalidate. It returns the cached response instantly and, in parallel, in the background, still goes to the network and refreshes the cache for next time. The user gets an immediate reaction, and the app gets gradually freshening data. The price is that the interface must understand it is showing a snapshot, possibly slightly stale, and not pass it off as the latest truth.
One detail matters in the code: the background update must not be left to chance. It is wrapped in event.waitUntil(), or the browser is free to put the worker to sleep right after it returns the cached response, and the update will not finish. The response itself is served immediately from the cache, and if there is no cache yet, you wait for the network as usual.
SWR's scope is drawn by the nature of the data. Avatars, the catalog, reference content - here an instant, if slightly stale, response is clearly better than waiting. But for an account balance, a price, access rights, or a payment result, a slightly old value is worse than an honest error: showing an unpaid order as paid is more dangerous than showing a spinner. And one more thing: if the background updated genuinely significant data, it is not enough to silently rewrite the cache - the open tabs must be told through postMessage, so the app updates the screen properly instead of leaving the user staring at stale figures that are already different in storage.
async function networkFirst(request, cacheName, timeoutMs = 2500) {
const cache = await caches.open(cacheName);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), timeoutMs)
);
try {
const response = await Promise.race([fetch(request), timeout]);
if (response.ok) await cache.put(request, response.clone());
return response;
} catch {
return (await cache.match(request)) ??
new Response(JSON.stringify({ offline: true }), {
status: 503, headers: { 'content-type': 'application/json' }
});
}
}async function staleWhileRevalidate(event, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
const update = fetch(event.request).then(async response => {
if (response.ok) await cache.put(event.request, response.clone());
return response;
});
event.waitUntil(update.catch(() => undefined));
return cached ?? update;
}