Service Worker Routing Strategies

A service worker turns every outbound request your page makes into a decision point: serve from the cache, go to the network, or blend the two. The interesting engineering is not in any single caching primitive but in the routing layer that inspects each fetch event and dispatches it to the right strategy — because a navigation request, a hashed JavaScript bundle, and a live API call each want a different answer. This page builds that dispatcher, explains which of the five canonical strategies fits which request class, and gives you framework-agnostic TypeScript you can drop into a fetch handler and reason about with confidence.

← Back to Service Worker & Offline Routing

The Problem

Once a service worker is installed and controlling a page, it intercepts every HTTP request in scope — the document itself, scripts and stylesheets, images, fonts, JSON from your API, and third-party beacons. A naive handler that applies one blanket policy to all of them fails in opposite directions depending on which policy you pick.

Serve everything cache-first and you get a blisteringly fast site that ships stale data: users see yesterday’s dashboard, a deployed bug fix never reaches the browser, and an expired session token loops the login screen. Serve everything network-first and you have thrown away the single biggest reason to run a service worker at all — the app no longer works offline, and every navigation waits on a round-trip even when a perfectly good cached copy exists. Neither extreme is a routing strategy; each is the absence of one.

The real requirement is per-request dispatch. The service worker has to classify each request — is this a navigation, a versioned static asset, an API read, an image, or an analytics ping? — and route it to the caching behaviour that matches that class’s tolerance for staleness and its need for freshness. That classification is a routing problem in the same family as client-side route matching: you inspect a request, rank candidate rules, and pick the most specific one. The difference is that here the “route table” maps request predicates to caching strategies rather than URLs to components.

Three properties make this harder than ordinary routing. The handler runs inside the fetch event, so it must call event.respondWith() synchronously with a Promise or the browser falls back to the network and ignores you. It runs in a worker with no DOM and a lifecycle that can be terminated between events, so all state lives in the Cache Storage API. And it governs correctness, not just speed — a wrong strategy on the app shell can pin users to a broken build until they manually clear storage.

Core API & Primitives

Three browser primitives underpin every strategy. The FetchEvent carries the intercepted Request and expects a Response (or a promise of one) passed to respondWith. The Cache interface stores request/response pairs. And caches.open(name) gives you a named, versioned cache so a new deployment can populate a fresh cache and delete the old one during activate.

// TypeScript 5.x — service worker (WebWorker lib), no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

type StrategyName =
  | "cache-first"
  | "network-first"
  | "stale-while-revalidate"
  | "network-only"
  | "cache-only";

interface RouteRule {
  // A predicate over the intercepted request; the first match wins.
  test: (request: Request, url: URL) => boolean;
  strategy: StrategyName;
  cacheName: string;      // which named cache this rule reads and writes
  networkTimeoutMs?: number; // optional guard for network-first
}

Each RouteRule pairs a predicate with a strategy and a target cache. A request’s mode distinguishes the classes cheaply: request.mode === "navigate" identifies a document navigation, request.destination reports "script", "style", "image", or "font" for sub-resources, and same-origin checks on the parsed URL separate your API from third parties. The five strategy names correspond to the five canonical behaviours below.

  • Cache-first returns the cached response if present and only touches the network on a miss. Best for content-addressed static assets — hashed bundles, immutable fonts — where the URL changes whenever the bytes change, so a cache hit is never stale by definition.
  • Network-first tries the network, falls back to cache on failure or timeout. Best for navigations and API reads where freshness matters but an offline copy beats a blank screen.
  • Stale-while-revalidate returns the cached response immediately and fetches an update in the background to refresh the cache for next time. Best for the app shell and semi-static assets where instant paint outweighs a one-navigation lag in freshness.
  • Network-only never touches the cache. Correct for non-idempotent requests (POST, PUT, analytics) that must reach the server and must not be replayed from storage.
  • Cache-only never touches the network. Reserved for precached resources you guarantee are present, such as an offline fallback page.
Five caching strategies compared by cache and network order Cache-first reads cache then network; network-first reads network then cache; stale-while-revalidate returns cache while revalidating from network; network-only and cache-only use a single source. Cache-first Cache miss Network Network-first Network fail Cache Stale-while-revalidate Cache serve Network background Network-only / Cache-only Single source no fallback
The five strategies differ only in the order they consult cache and network, and whether a fallback exists.

Step-by-Step Implementation

The prerequisite is a registered, activated service worker controlling the page (registration from the main thread and the install/activate lifecycle are covered in the Service Worker & Offline Routing overview). The steps below build a small strategy router inside the fetch handler, one strategy function at a time, then wire them to a rule table.

Step 1: Define the strategy functions

Each strategy is a pure async function from Request to Response. Implementing them in isolation keeps the router trivial and each behaviour independently testable.

// TypeScript 5.x — service worker context, no runtime dependencies
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
  const cached = await caches.match(req);
  if (cached) return cached;
  const res = await fetch(req);
  if (res.ok) (await caches.open(cacheName)).put(req, res.clone());
  return res;
}

async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(req);
  const network = fetch(req).then((res) => {
    if (res.ok) cache.put(req, res.clone());
    return res;
  });
  // Serve cache immediately when present; otherwise wait on the network.
  return cached ?? network;
}

Step 2: Add network-first with a timeout guard

A network-first strategy that waits indefinitely on a stalled connection defeats its own purpose. Race the fetch against a timer so a flaky network degrades to the cached copy instead of hanging the navigation.

// TypeScript 5.x — service worker context
async function networkFirst(req: Request, cacheName: string, timeoutMs = 3000): Promise<Response> {
  const cache = await caches.open(cacheName);
  try {
    const res = await withTimeout(fetch(req), timeoutMs);
    if (res.ok) cache.put(req, res.clone());
    return res;
  } catch {
    const cached = await cache.match(req);
    if (cached) return cached;
    throw new Error("network-first: no network and no cache");
  }
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return new Promise((resolve, reject) => {
    const id = setTimeout(() => reject(new Error("timeout")), ms);
    promise.then((v) => { clearTimeout(id); resolve(v); },
                 (e) => { clearTimeout(id); reject(e); });
  });
}

Step 3: Declare the routing table

Order rules from most specific to least, exactly as a URL matcher ranks by specificity. Navigations come first, then versioned assets, then API reads, with a catch-all default. The cache-first-vs-network-first decision for the navigation rule is examined in depth in its own guide.

// TypeScript 5.x — service worker context
const VERSION = "v3";
const SHELL_CACHE = `shell-${VERSION}`;
const ASSET_CACHE = `assets-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;

const rules: RouteRule[] = [
  { test: (req) => req.mode === "navigate",
    strategy: "network-first", cacheName: SHELL_CACHE, networkTimeoutMs: 3000 },
  { test: (req) => ["script", "style", "font"].includes(req.destination),
    strategy: "cache-first", cacheName: ASSET_CACHE },
  { test: (req) => req.destination === "image",
    strategy: "stale-while-revalidate", cacheName: ASSET_CACHE },
  { test: (_req, url) => url.pathname.startsWith("/api/"),
    strategy: "network-first", cacheName: DATA_CACHE, networkTimeoutMs: 2000 },
];

const fallbackRule: RouteRule = {
  test: () => true, strategy: "network-only", cacheName: ASSET_CACHE,
};

Step 4: Dispatch inside the fetch handler

Resolve the matching rule, then call the strategy it names. Only intercept GET; let non-idempotent methods pass straight through so a background sync or a form POST is never served from storage.

// TypeScript 5.x — service worker context
function resolveRule(req: Request, url: URL): RouteRule {
  return rules.find((rule) => rule.test(req, url)) ?? fallbackRule;
}

function runStrategy(rule: RouteRule, req: Request): Promise<Response> {
  switch (rule.strategy) {
    case "cache-first": return cacheFirst(req, rule.cacheName);
    case "network-first": return networkFirst(req, rule.cacheName, rule.networkTimeoutMs);
    case "stale-while-revalidate": return staleWhileRevalidate(req, rule.cacheName);
    case "cache-only": return caches.match(req).then((r) => r ?? Response.error());
    case "network-only": return fetch(req);
  }
}

self.addEventListener("fetch", (event) => {
  const req = event.request;
  if (req.method !== "GET") return; // let the network handle mutations
  const url = new URL(req.url);
  event.respondWith(runStrategy(resolveRule(req, url), req));
});

Step 5: Attach an offline fallback

Wrap the navigation strategy so a total miss — no network, no cached document — returns a precached offline page instead of the browser’s error screen. This is where cache-only earns its place.

// TypeScript 5.x — service worker context
const OFFLINE_URL = "/offline-fallback";

self.addEventListener("fetch", (event) => {
  const req = event.request;
  if (req.method !== "GET") return;
  const url = new URL(req.url);
  const rule = resolveRule(req, url);
  event.respondWith(
    runStrategy(rule, req).catch(async () =>
      req.mode === "navigate"
        ? (await caches.match(OFFLINE_URL)) ?? Response.error()
        : Response.error(),
    ),
  );
});
A strategy per request type Six request types matched to a caching strategy: immutable assets cache-first, the shell stale-while-revalidate, navigations network-first with a timeout, API calls network-first with a short timeout, images cache-first with expiry, and beacons never cached. Choosing a strategy per request type strategy because hashed JS and CSS cache-first, immutable the filename changes when the content does the app shell HTML stale-while-revalidate instant paint, refreshed behind it a navigation to fresh content network-first with a timeout freshness matters, hanging does not API responses network-first, short timeout stale data is worse than a retry user avatars and thumbnails cache-first with expiry rarely change, cheap to hold analytics beacons never cache let them fail
One strategy for the whole origin is the common mistake; the right granularity is the request type, not the site.

Verification & Testing

Verify two things: that each request class routes to the strategy you intended, and that the whole page survives going offline. DevTools gives you both — the Application panel’s Service Workers pane has an Offline checkbox, and the Network panel labels each response (ServiceWorker) when it came from your handler. For a repeatable check, drive it with Playwright and toggle the browser context offline mid-session.

// @playwright/test v1.44 — run against a built, service-worker-enabled site
import { test, expect } from "@playwright/test";

test("navigations still resolve after going offline", async ({ page, context }) => {
  await page.goto("/");                       // warms the shell + asset caches
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  await context.setOffline(true);             // kill the network
  await page.reload();                        // navigation now hits the cache
  await expect(page.locator("#app")).toBeVisible();

  await page.goto("/does-not-exist");         // total miss → offline fallback
  await expect(page.locator("[data-offline]")).toBeVisible();
});

For a unit-level check without a browser, import the resolveRule function directly and assert that a synthetic new Request("/api/users") resolves to network-first while new Request("/app.abc123.js", { }) with a script destination resolves to cache-first.

Performance Tuning

  • Precache the shell at install time. Populate SHELL_CACHE during the install event so the very first offline navigation has something to fall back to, rather than waiting for a runtime miss to seed it. The mechanics live on the App Shell Caching page.
  • Keep network-first timeouts tight. A 2–3 second guard is the difference between a resilient app and one that appears to hang on hotel Wi-Fi. Tune per rule: API reads can afford less patience than a full navigation.
  • Prefer stale-while-revalidate for anything that can lag one navigation. It removes the network from the critical path entirely while still converging on fresh content, which is usually the best trade for images, avatars, and the app shell.
  • Cap cache growth. Cache Storage has an origin quota; an unbounded DATA_CACHE will eventually be evicted wholesale by the browser. Trim least-recently-used entries during activate or on a periodic message from the page.
  • Never cache opaque responses blindly. A cross-origin no-cors response has status 0 and an opaque body that counts against quota at its full padded size; gate cache.put on res.ok as every strategy above does.

Matching Strategy to Request, Not to Site

The most common service worker mistake is choosing one strategy and applying it to everything. It is understandable — the strategies are usually presented as alternatives — but a site is not one kind of request, and the right granularity is the request type rather than the origin.

Start by splitting requests into four groups, because each has a different tolerance for staleness.

Immutable assets are anything whose filename changes when its contents change: hashed JavaScript, hashed CSS, versioned images. These can be cached forever with no revalidation, because a changed file is a different URL. Cache-first is not merely acceptable here, it is optimal, and the only maintenance is deleting old versions during activate so storage does not grow without bound.

The application shell — the HTML that frames every route — changes rarely but does change. Stale-while-revalidate fits it precisely: serve the cached copy for an instant paint, fetch a fresh one in the background, and use the new one next time. The cost is exactly one visit of staleness per deployment, which is why pairing it with an “update available” affordance is worth the small amount of extra code.

Content navigations are documents whose freshness matters to the reader. Network-first is right, but only with a timeout: without one, a stalled connection produces a blank tab rather than a fallback, which is the failure the plain strategy handles worst. The budget is a product decision, and adapting it to the reported connection quality avoids punishing users whose network is slow but working.

Personalised and authenticated responses should usually not be cached at all. The risk is not staleness but correctness: a cached response keyed only by URL can be served to a different user in the same browser profile, and no amount of expiry tuning makes that safe. Either exclude them, or key the cache on the session as well — and prefer excluding them, because the simple rule is the one that survives a refactor.

Everything else in this section is an optimisation within one of those four groups. Getting the grouping right first is what makes the optimisations worth doing.

Gotchas & Failure Modes

  • Forgetting to clone() before caching. A Response body is a one-shot stream; if you cache.put(req, res) and also return res, one consumer gets an empty body. Always cache res.clone() and return the original.
  • Serving a stale app shell forever. Cache-first on the HTML document pins users to whatever build first landed; a deployed fix never arrives. Route navigations through network-first or stale-while-revalidate, never cache-first.
  • Intercepting POST requests. Passing a non-idempotent request through a caching strategy can replay a mutation from storage or swallow it offline. Bail out early on req.method !== "GET".
  • Caching error and redirect responses. A cached 404 or 302 is nearly impossible to distinguish from a real one later. Only put when res.ok and res.type !== "opaqueredirect".
  • Version skew between caches and code. If the activate handler does not delete caches from prior VERSION values, old assets linger and quota fills. Enumerate caches.keys() and drop anything not in the current allow-list.
  • Assuming respondWith can be called late. It must run synchronously in the event’s dispatch; awaiting something before calling it hands the request back to the browser. Call respondWith with a promise, do the awaiting inside.
The failure mode of each mis-chosen strategy Five strategy misapplications with the resulting failure and who experiences it, from a pinned stale build to a service worker that adds boot latency without providing any offline benefit. What each strategy costs when it is wrong failure who notices cache-first on mutable HTML a stale build pinned indefinitely everyone, eventually network-first with no timeout a hang on a stalled connection users on poor networks stale-while-revalidate on an API yesterday's data shown as current the user acting on it cache-only with no fallback a hard failure on a cache miss users after an eviction no strategy at all the worker adds boot latency for nothing every visitor
The last row is the most common: a worker that intercepts navigations but caches nothing useful is a pure cost.