Cache-First vs Network-First for Navigation Requests
After reading this you will be able to decide, with a concrete framework rather than a hunch, whether your service worker should answer a document navigation from the cache or from the network — and implement whichever you choose in a single, readable fetch handler.
← Back to Service Worker Routing Strategies
Prerequisites
Core Concept
A navigation request is the one the browser makes for the top-level document when a user types a URL, clicks a link, or hits back — anything that swaps the whole page. In a service worker it is distinguished not by path but by request.mode === "navigate", which is why the routing table matches it first, before any per-URL rule. The strategy you attach to that single predicate decides more about the app’s felt behaviour than any other rule, because it governs the first paint of every full page load.
The two candidates pull in opposite directions. Cache-first returns the stored document instantly and only reaches the network on a miss, so navigations are sub-frame fast and work offline out of the box — but the user is served whatever HTML was cached, which can be a stale or even broken build until the cache is deliberately refreshed. Network-first asks the server for the current document and falls back to the cache only when the network fails or times out, so users always get the latest markup when connected, at the cost of a round-trip on every navigation and a hard dependency on the fallback being present for offline to work at all.
The deciding question is what your navigation document contains. If it is a thin, content-agnostic app shell — an empty scaffold that boots a SPA and fetches its real content client-side — the HTML changes only when you redeploy, so cache-first (or its gentler cousin, stale-while-revalidate) is safe and fast. If the navigation document is server-rendered with real, per-request content baked into the markup, cache-first would serve one user’s page to the next visitor or freeze content at deploy time, so network-first is the correct default.
Implementation
The handler below routes navigations through network-first with a timeout, and shows the one-line change to flip to cache-first. The timeout is the crux: without it, network-first inherits the network’s worst-case latency on every navigation.
// TypeScript 5.x — service worker context, no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const SHELL_CACHE = "shell-v3";
const OFFLINE_URL = "/offline-fallback";
const NAV_TIMEOUT_MS = 3000;
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const id = setTimeout(() => reject(new Error("nav-timeout")), ms);
p.then((v) => { clearTimeout(id); resolve(v); },
(e) => { clearTimeout(id); reject(e); });
});
}
// Network-first: freshest markup when online, cached document when not.
async function navigateNetworkFirst(req: Request): Promise<Response> {
const cache = await caches.open(SHELL_CACHE);
try {
const res = await withTimeout(fetch(req), NAV_TIMEOUT_MS);
if (res.ok) cache.put(req, res.clone()); // refresh the fallback copy
return res;
} catch {
return (await cache.match(req))
?? (await cache.match(OFFLINE_URL))
?? Response.error();
}
}
// Cache-first alternative: swap the call below for a static app shell.
// async function navigateCacheFirst(req: Request): Promise<Response> {
// const cache = await caches.open(SHELL_CACHE);
// return (await cache.match("/index.html"))
// ?? navigateNetworkFirst(req); // revalidate on a cold cache
// }
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET" || req.mode !== "navigate") return;
event.respondWith(navigateNetworkFirst(req));
});
The decision framework in one pass: choose network-first when the navigation returns server-rendered per-request content, when data freshness is a correctness concern, or when a stale document could strand users on a broken deploy. Choose cache-first when the navigation returns a static app shell that only changes on redeploy, when instant paint is the priority, and when your deployment already busts the cache by versioning its cache name. When cache-first’s staleness worries you but its speed tempts you, reach for stale-while-revalidate instead — it is the subject of a companion guide.
Verification
Confirm both online freshness and offline resilience with a single Playwright flow that toggles the network mid-test. The Application panel’s Offline checkbox does the same manually.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("network-first serves fresh online and cached offline", async ({ page, context }) => {
await page.goto("/");
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
// Online: the network response, not a cached one.
const online = await page.goto("/dashboard");
expect(online?.headers()["x-served-by"]).toBe("origin");
// Offline: the cached document still renders.
await context.setOffline(true);
await page.reload();
await expect(page.locator("#app")).toBeVisible();
});
Gotchas
- Cache-first on a server-rendered document leaks content. If the HTML carries per-user data, one visitor’s cached page can be served to the next. Only apply cache-first to a content-agnostic shell.
- A missing timeout makes network-first feel broken offline-ish. On a captive-portal or high-latency link the fetch hangs until the browser’s own timeout; always race it against a short
NAV_TIMEOUT_MSand fall back. - Forgetting
res.clone()beforecache.put. The response body is consumed once — cache the clone and return the original, or the user sees a blank page. - No offline fallback on a cold cache. Network-first that finds neither network nor a matching cached document must still return something; chain to a precached offline fallback page rather than
Response.error().
FAQ
How does the service worker know a request is a navigation? It reads request.mode, which the browser sets to navigate for any top-level document load — link click, address-bar entry, or back/forward — regardless of the URL. You match on that mode rather than on a path so the rule covers every route.
Is cache-first ever safe for the HTML document? Yes, when the document is a static app shell that contains no per-request content and only changes when you redeploy. Bust the cache by versioning its name on each release, and cache-first gives you instant, offline-capable navigations without serving stale user data.
Why add a timeout to network-first instead of just awaiting fetch? Because a fetch on a stalled or captive network can hang far longer than users will tolerate, and there is no other signal to fall back to the cache. Racing the fetch against a two-to-three second timer turns a hang into a fast, resilient degrade.
What should network-first return when both network and cache miss? A precached offline fallback page, served cache-only, so the user sees a designed offline state instead of the browser’s error screen. Reserve Response.error() for non-navigation requests where no fallback makes sense.
Does network-first hurt my Largest Contentful Paint? It can, because every navigation waits on a round-trip before first paint. If LCP matters more than absolute freshness, move the shell to stale-while-revalidate so the cached document paints immediately while the update lands in the background.
Related
- Service Worker Routing Strategies — the parent guide that builds the full strategy router this rule plugs into.
- Stale-While-Revalidate for the App Shell — the middle path when cache-first is too stale and network-first is too slow.
- App Shell Caching — how to seed the cached document that both strategies depend on for their offline fallback.