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.

Cache-first and network-first across five navigation types Five navigation types classified by which cache ordering suits each. Personalised and authenticated pages must be network-first; an impersonal shell can be cache-first. Which navigation deserves which order cache-first network-first an app shell that never changes per user yes — instant unnecessary latency a personalised dashboard stale, possibly another user's yes a documentation article yes, with revalidation acceptable too a checkout step never cache yes, always fresh a page behind auth risk of cross-user leakage yes, and vary on the session
The last row is a correctness question rather than a performance one — a cached authenticated page can be served to the wrong person.

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();
});
Cache keys and cross-user safety Four cache key strategies for authenticated navigations, from URL-only which is unsafe, through per-session keys, never caching authenticated pages, and honouring the Vary header. Cache keys that cause cross-user leaks key includes safe? the URL only ignores the session entirely no, for authenticated pages URL + a session identifier one entry per user yes, but it grows URL, with auth pages never cached the simple rule yes URL + Vary on Cookie respects the header yes, if the origin sets it
The third row is boring and correct, which is usually the right trade for anything behind a login.

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_MS and fall back.
  • Forgetting res.clone() before cache.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.

Cache order chosen by the cost of staleness Five levels of staleness cost, from harmless through confusing and wrong to dangerous and unauthorised, each mapped to the cache ordering that suits it. Choosing an order by what staleness costs if stale content is… order harmless — a marketing page cache-first instant, refresh behind it confusing — a docs page mid-rewrite stale-while-revalidate instant plus a refresh prompt wrong — a live dashboard network-first with a timeout fresh, bounded wait dangerous — a balance or an order network-only no cache at all unauthorised — another user's page never cache a correctness rule
Working down the table, the question stops being about speed and becomes about correctness.