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(),
    ),
  );
});

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.

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.

Go Deeper

  • Service Worker & Offline Routing — the parent overview covering the service worker lifecycle, registration, and offline architecture.
  • App Shell Caching — precaching the minimal HTML, CSS, and JS that boots your SPA so navigations resolve instantly and offline.
  • Offline Fallback Pages — designing the cache-only page users see when both network and cache miss.
  • Route Matching Algorithms — the client-side analogue of matching a request to a rule, with specificity ranking and traversal.