Service Worker Cache Invalidation for Fingerprinted Builds

A service worker is a cache you wrote yourself, running in front of the browser HTTP cache and in front of every CDN edge in the path, and unlike those layers nobody can purge it for you — a stale index.html held in Cache Storage will happily request asset hashes that were deleted from the origin two deploys ago.

Everything the Cache-Control and TTL reference says about immutable and s-maxage still holds, but it stops at the edge of the browser process. Cache Storage does not read Cache-Control at all. Once a response is in a named cache it stays there until your code deletes it, which means invalidation stops being a header problem and becomes a lifecycle problem: cache naming, install/activate ordering, and which strategy each URL shape gets.

When to Use a Service Worker Cache at All

A service worker earns its complexity when you need behaviour the HTTP cache cannot express: offline navigation, instant repeat loads on flaky mobile networks, or a precached app shell that renders before the network responds. It is a liability when you only want faster asset delivery — hashed filenames plus max-age=31536000, immutable already give you a permanent client cache with zero code and zero deploy risk.

Requirement CDN + Cache-Control alone Service worker
Repeat-visit asset speed Disk cache hit, near-instant Cache Storage hit, comparable
Works with no network No Yes, if precached
Survives a CDN purge No — purge invalidates the edge Yes — the client copy is untouched
Invalidation control Headers, purge API Your own activate handler
Blast radius of a mistake Bounded by TTL Unbounded until a new SW installs
Rollback path Re-purge, redeploy headers Ship a replacement SW, wait for update check
Extra bytes on first load 0 SW script + precache payload

The asymmetry in the last three rows is the whole reason this page exists. A bad Cache-Control header expires. A bad service worker does not, and the remediation is a separate incident procedure covered in forcing an update after a bad deploy.

Cache layers around a fingerprinted asset Five stacked bands show the request path: page request, service worker Cache Storage, the browser HTTP cache, the CDN edge, and the origin build output. Each band is annotated with the control that governs it. Where the service worker sits Page request fetch or navigation start browser tab Service worker Cache Storage, your code you invalidate this HTTP cache obeys Cache-Control max-age, immutable CDN edge shared cache, purge API s-maxage, purge Origin build output hashed files on disk source of truth A CDN purge clears the bottom half only. The top two layers need their own invalidation.
A purge at the edge never reaches Cache Storage — the service worker layer has to invalidate itself.

Prerequisites

Component Version / requirement
Workbox 7.x (workbox-build, workbox-cli, workbox-webpack-plugin)
Webpack 5.x with output.filename: '[name].[contenthash:8].js'
Vite 5.x — hashed filenames by default; pair with vite-plugin-pwa 0.19+
Node 18.x or 20.x for workbox-build
Transport HTTPS, or http://localhost for development
Origin control Ability to set Cache-Control on /sw.js independently of other assets

Hash length in every example below is 8 hex characters, matching the default of Webpack’s [contenthash:8] and Rollup’s [hash]. Monorepos emitting thousands of chunks should move to 12–16 characters; the service worker code does not care about the length, only that the substring is stable per build.

The last row is not optional. If your CDN applies one blanket Cache-Control rule to everything under /, the service worker script itself inherits a long TTL and you lose the ability to ship a fix. Carve out an exception before you register anything.

Configuration Reference

Key Type Default Effect
CACHE_VERSION (your constant) string none Suffix on every Cache Storage name; bump it to orphan the previous generation
self.skipWaiting() call not called Activates the new worker without waiting for existing tabs to close
self.clients.claim() call not called Makes the active worker control already-open tabs immediately
event.waitUntil(p) call none Extends install/activate until p settles; failures abort the install
navigationPreload.enable() call disabled Starts the navigation request in parallel with worker boot-up
registration.updateViaCache 'imports' | 'all' | 'none' 'imports' Whether the HTTP cache may satisfy the fetch of the SW script and its imports
registration.update() call automatic Forces an immediate byte-comparison check of the SW script
Workbox globDirectory string none Build directory scanned to produce the precache manifest
Workbox globPatterns string[] ['**/*.{js,css,html}'] Which built files enter the precache manifest
Workbox dontCacheBustURLsMatching RegExp undefined URLs already fingerprinted; suppresses the revision field
Workbox maximumFileSizeToCacheInBytes number 2097152 Files above this are silently dropped from the manifest
Workbox cleanupOutdatedCaches boolean false Deletes precaches written by earlier Workbox versions on activate
Workbox navigateFallback string undefined Precached document served for navigations that miss — the classic stale-HTML trap
Workbox runtimeCaching[].handler string none CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly, CacheOnly

updateViaCache deserves a second look. Its default, 'imports', means the browser bypasses the HTTP cache for the top-level sw.js but allows it for anything the worker pulls in via importScripts(). If you host Workbox runtime files yourself and they are not fingerprinted, 'imports' can pin an old runtime under a fresh worker. Set updateViaCache: 'none' at registration and let the origin’s short TTL do the work.

Step-by-Step Implementation

1. Decide the URL classes before writing any code

Every request the worker sees falls into exactly one of four classes, and the strategy follows mechanically from the class. Do this on paper first; retrofitting it later is how stale-HTML incidents happen.

  • Fingerprinted assets/assets/main.a3f8c1d2.js. The URL is a content address, so CacheFirst is not merely safe, it is correct. There is nothing to revalidate.
  • Navigation documents/, /pricing/, any HTML. Mutable at a stable URL, so NetworkFirst with a cache fallback.
  • Unhashed static files/favicon.ico, /manifest.webmanifest. StaleWhileRevalidate with a short expiration.
  • API and cross-origin requests — pass through untouched unless you have a specific reason.

The split mirrors the one described in content hashing versus semantic versioning: a content-addressed URL is a promise that the bytes never change, and every cache in the chain — including yours — is entitled to rely on it.

Routing a fetch event by URL shape A fetch event splits into three branches — navigation documents, hashed assets, and unhashed or API requests — each mapped to a caching strategy with its rationale. fetch event Navigation document mutable at a fixed URL Fingerprinted asset main.a3f8c1d2.js Unhashed or API no hash in the name Network-first fall back to cache offline refresh copy on every 200 Cache-first the URL can never change no revalidation needed Network-only or SWR with a short TTL never precache these
Strategy follows the shape of the URL, not the file extension — a hash in the name is what licenses cache-first.

2. Name and version the caches

Cache Storage is a flat namespace of string-keyed caches scoped to the origin. The name is your only versioning primitive, so encode the build identity in it and treat any cache whose name is not in the current allow-list as garbage.

Two names, not one. Assets and documents have different lifetimes: an asset cache can survive across builds because its entries are content-addressed, while the document cache must be dropped whenever the HTML changes. Keeping them separate lets you throw away only the half that went stale.

3. Write the service worker

This is a complete, runnable worker. Drop it at the origin root as /sw.js; the BUILD_ID line is the one thing your pipeline rewrites per deploy.

// sw.js — hand-written service worker for a fingerprinted build.
// BUILD_ID is replaced at deploy time (see the CI snippet further down).
const BUILD_ID = '2026-07-29-a3f8c1d2';

const ASSET_CACHE = `assets-${BUILD_ID}`;
const DOC_CACHE = `docs-${BUILD_ID}`;
const CURRENT_CACHES = [ASSET_CACHE, DOC_CACHE];

// App shell precached on install. These filenames are emitted by the build,
// so this array is generated — see step 5 for the Workbox equivalent.
const PRECACHE = [
  '/',
  '/offline/',
  '/assets/main.a3f8c1d2.js',
  '/assets/main.7b41e9c0.css',
  '/assets/logo.4d2f8a11.svg',
];

const HASHED = /\/assets\/[^/]+\.[0-9a-f]{8,16}\.(?:js|mjs|css|woff2|png|webp|avif|svg)$/;

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(ASSET_CACHE);
    // addAll is atomic: one 404 aborts the whole install and the old
    // worker keeps control. That is the behaviour you want.
    await cache.addAll(PRECACHE.filter((u) => HASHED.test(u)));
    const docs = await caches.open(DOC_CACHE);
    await docs.addAll(PRECACHE.filter((u) => !HASHED.test(u)));
  })());
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
    }
    const names = await caches.keys();
    await Promise.all(
      names
        .filter((name) => !CURRENT_CACHES.includes(name))
        .map((name) => caches.delete(name))
    );
    await self.clients.claim();
  })());
});

self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  if (request.mode === 'navigate') {
    event.respondWith(networkFirstDocument(event));
    return;
  }
  if (HASHED.test(url.pathname)) {
    event.respondWith(cacheFirstImmutable(request));
  }
});

async function cacheFirstImmutable(request) {
  const cache = await caches.open(ASSET_CACHE);
  const hit = await cache.match(request);
  if (hit) return hit;

  const response = await fetch(request);
  if (response.ok) {
    cache.put(request, response.clone());
  }
  return response;
}

async function networkFirstDocument(event) {
  const cache = await caches.open(DOC_CACHE);
  try {
    const preloaded = await event.preloadResponse;
    const response = preloaded || await fetch(event.request);
    if (response.ok) {
      cache.put(event.request, response.clone());
    }
    return response;
  } catch (err) {
    const hit = await cache.match(event.request);
    if (hit) return hit;
    const offline = await cache.match('/offline/');
    return offline || Response.error();
  }
}

// Let the page ask for an immediate takeover after it has told the user.
self.addEventListener('message', (event) => {
  if (event.data === 'SKIP_WAITING') self.skipWaiting();
});

Three details carry the invalidation contract. addAll is atomic, so a precache referencing a hash the CDN has not finished propagating fails the install and leaves the previous worker in charge — a safe failure. The activate handler deletes by allow-list rather than by pattern, so a cache name you forgot about still gets collected. And skipWaiting is behind a message rather than called unconditionally, which is the trade-off examined next.

4. Register, and handle the update

Registration is where updateViaCache and the update prompt live. Never register before load on a first visit — the worker competes with the critical path for bandwidth.

// app.js — registration and update handling in the page.
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
      updateViaCache: 'none',
    });

    // A worker is waiting: a new build is installed but not yet in control.
    if (registration.waiting) promptForUpdate(registration);

    registration.addEventListener('updatefound', () => {
      const installing = registration.installing;
      if (!installing) return;
      installing.addEventListener('statechange', () => {
        if (installing.state === 'installed' && navigator.serviceWorker.controller) {
          promptForUpdate(registration);
        }
      });
    });

    // Check for a new worker when the tab regains focus, not on a timer.
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') registration.update();
    });
  });

  // Reload exactly once when control transfers to the new worker.
  let reloading = false;
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (reloading) return;
    reloading = true;
    window.location.reload();
  });
}

function promptForUpdate(registration) {
  const banner = document.getElementById('update-banner');
  if (!banner) return;
  banner.hidden = false;
  banner.querySelector('button').addEventListener('click', () => {
    registration.waiting && registration.waiting.postMessage('SKIP_WAITING');
  });
}

The reloading guard matters. Without it, controllerchange fires again during the reload and you get a refresh loop that survives until the user closes the tab.

5. Understand what skipWaiting actually costs

By default a newly installed worker sits in the waiting state until every tab controlled by the old worker is gone. That is deliberate: it guarantees one page session sees exactly one worker version, and therefore one asset generation. skipWaiting() discards that guarantee in exchange for faster rollout.

The failure it introduces is subtle. A single-page app that lazy-loads route chunks holds an HTML document from build N. If a skipWaiting worker from build N+1 takes over mid-session and its activate handler has just deleted the build-N asset cache, the next dynamic import asks the network for a chunk hash that the origin may no longer serve. That is precisely the stale HTML with new hashes failure, arriving from inside the browser rather than from the edge.

Waiting versus skipWaiting rollout The upper timeline shows a new worker installing and waiting until tabs close before it controls. The lower timeline shows skipWaiting taking control immediately, with open tabs able to swap versions mid-session. Rollout: waiting vs skipWaiting new SW installed Default browser waits v1 controls v2 installed, waiting old tabs still on v1 v2 controls after reload skipWaiting plus claim() v1 active install v2 controls immediately open tabs can swap version mid-session elapsed time
Waiting trades rollout speed for a guarantee that one page session sees one asset generation.

Use skipWaiting() unconditionally only when the app is a document-oriented site whose pages do not lazy-load code. For anything with route-level code splitting, gate it behind a user action and pair it with the single-shot reload above.

6. Generate the precache manifest with Workbox

Hand-maintaining PRECACHE is untenable once the build emits dozens of chunks. generateSW writes the whole worker for you and is the right choice when your strategies are expressible as configuration.

// workbox-generate.mjs — run after the production build.
import { generateSW } from 'workbox-build';

const { count, size, warnings } = await generateSW({
  globDirectory: 'dist',
  globPatterns: ['**/*.{js,mjs,css,woff2,svg,png,webp,html}'],
  globIgnores: ['**/sw.js', '**/*.map', '**/stats.json'],
  swDest: 'dist/sw.js',
  // Files that already carry a content hash need no revision field.
  dontCacheBustURLsMatching: /\.[0-9a-f]{8,16}\.[a-z0-9]+$/,
  maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
  cleanupOutdatedCaches: true,
  clientsClaim: true,
  skipWaiting: false,
  navigateFallback: null,
  runtimeCaching: [
    {
      urlPattern: ({ request }) => request.mode === 'navigate',
      handler: 'NetworkFirst',
      options: {
        cacheName: 'docs',
        networkTimeoutSeconds: 4,
        expiration: { maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 7 },
      },
    },
    {
      urlPattern: /\/assets\/[^/]+\.[0-9a-f]{8,16}\.[a-z0-9]+$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'hashed-assets',
        expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 90 },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
  ],
});

console.log(`precached ${count} files, ${(size / 1024).toFixed(1)} KiB`);
if (warnings.length) console.warn(warnings.join('\n'));

Two settings do the heavy lifting. dontCacheBustURLsMatching tells Workbox that a URL matching the pattern is already content-addressed, so the manifest entry gets revision: null instead of a duplicate MD5 — a saving worth several kilobytes on a large build and, more importantly, the thing that stops a chunk from being re-downloaded when only its revision string moved. navigateFallback: null disables the precached-shell-for-every-navigation behaviour, which is the single most common source of stale HTML.

The trade-offs between precaching everything here and letting the runtime routes populate lazily are laid out in precache manifest versus runtime caching.

7. Or keep your own worker and inject the manifest

injectManifest is the middle path: you write the logic, Workbox only substitutes the file list into a placeholder.

// workbox-inject.mjs
import { injectManifest } from 'workbox-build';

const { count, size } = await injectManifest({
  globDirectory: 'dist',
  globPatterns: ['**/*.{js,mjs,css,woff2}'],
  globIgnores: ['**/*.map'],
  dontCacheBustURLsMatching: /\.[0-9a-f]{8,16}\.[a-z0-9]+$/,
  swSrc: 'src/sw-src.js',
  swDest: 'dist/sw.js',
  injectionPoint: 'self.__WB_MANIFEST',
});

console.log(`injected ${count} entries, ${(size / 1024).toFixed(1)} KiB`);
// src/sw-src.js — the source worker; __WB_MANIFEST is replaced at build time.
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  new NavigationRoute(
    new NetworkFirst({
      cacheName: 'docs',
      networkTimeoutSeconds: 4,
      plugins: [new CacheableResponsePlugin({ statuses: [200] })],
    })
  )
);

registerRoute(
  /\/assets\/[^/]+\.[0-9a-f]{8,16}\.[a-z0-9]+$/,
  new CacheFirst({
    cacheName: 'hashed-assets',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({ maxEntries: 200, purgeOnQuotaError: true }),
    ],
  })
);

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});

purgeOnQuotaError: true is not optional on mobile. When the browser raises QuotaExceededError, Workbox deletes that cache wholesale and retries rather than leaving the worker in a half-populated state.

8. Why revision is redundant for fingerprinted files

A Workbox precache manifest entry is a url plus a revision. The revision exists so Workbox can tell whether /styles.css changed between builds — it hashes the file contents and stores the digest. For /assets/main.a3f8c1d2.js that digest is pure duplication: the URL already encodes the content, so two builds that produce the same URL necessarily produce the same bytes.

[
  { "url": "/assets/main.a3f8c1d2.js", "revision": null },
  { "url": "/assets/main.7b41e9c0.css", "revision": null },
  { "url": "/manifest.webmanifest", "revision": "9c1e4d77f0b2a6538e41" },
  { "url": "/offline/index.html", "revision": "41ab73c0d95e28f61b40" }
]

Leaving a non-null revision on a hashed URL has a real cost. Workbox keys its precache bookkeeping on url + revision; if the revision string changes for any reason — a whitespace-only rebuild, a different Workbox version, a non-deterministic build — Workbox considers the entry outdated and re-downloads a byte-identical file on the next install. On a 200-chunk build that is megabytes of pointless transfer per deploy. Set dontCacheBustURLsMatching and confirm the manifest shows null.

9. Serve the worker script with a short TTL

The service worker script is the one file in your deployment that must never be cached aggressively. Browsers do check for updates on their own — on navigation, and at most once every 24 hours — but that check is only useful if it reaches your origin.

server {
    listen 443 ssl;
    server_name example.com;
    root /var/www/dist;

    # The worker script: always revalidate, never let a CDN pin it.
    location = /sw.js {
        add_header Cache-Control "no-cache, max-age=0, must-revalidate" always;
        add_header Service-Worker-Allowed "/" always;
        expires -1;
    }

    # Fingerprinted assets: one year, immutable.
    location ~* ^/assets/[^/]+\.[0-9a-f]{8,16}\.(js|mjs|css|woff2|png|webp|avif|svg)$ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        expires 1y;
        access_log off;
    }

    # Documents: short edge TTL, browser revalidates.
    location / {
        add_header Cache-Control "public, s-maxage=300, no-cache" always;
        try_files $uri $uri/index.html /index.html;
    }
}

The Cloudflare equivalent is a Cache Rule matching http.request.uri.path eq "/sw.js" with Edge TTL and Browser TTL both set to Bypass, ordered above your catch-all asset rule. On CloudFront, give /sw.js its own cache behaviour with the managed CachingDisabled policy. The reasoning behind the asset TTLs is unpacked in the Cache-Control and TTL reference, and the short document TTL pairs naturally with stale-while-revalidate on HTML entry points.

10. Stamp the build identity in CI

#!/usr/bin/env bash
# deploy.sh — stamp the worker, publish assets first, documents last.
set -euo pipefail

BUILD_ID="$(git rev-parse --short=8 HEAD)"

npm run build
node workbox-generate.mjs

# Stamp the build id so CACHE_VERSION changes on every deploy.
sed -i "s/__BUILD_ID__/${BUILD_ID}/g" dist/sw.js

# Assets before documents: a document must never reference a missing hash.
aws s3 sync dist/assets s3://example-com-origin/assets \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.map"

aws s3 sync dist s3://example-com-origin \
  --exclude "assets/*" --exclude "sw.js" \
  --cache-control "public, s-maxage=300, no-cache" \
  --delete

# The worker script last, and never with a long TTL.
aws s3 cp dist/sw.js s3://example-com-origin/sw.js \
  --cache-control "no-cache, max-age=0, must-revalidate"

echo "deployed ${BUILD_ID}"

Ordering is the whole point: assets first, documents second, worker last. Reversing it opens a window in which a freshly installed worker precaches URLs the origin has not received yet, and addAll fails the install. The same ordering discipline appears in the CI/CD asset pipeline guide.

Verification

DevTools, Application panel

  1. Open Application → Service Workers. Confirm exactly one registration for the scope, that Status reads activated and is running, and that the Source timestamp matches your deploy.
  2. Tick Update on reload while developing. Untick it before testing real update behaviour — it hides exactly the bugs you are hunting.
  3. Open Application → Cache Storage. You should see the current generation only. A list containing assets-2026-07-21-… alongside assets-2026-07-29-… means your activate handler is not deleting by allow-list.
  4. Expand the asset cache and confirm the entries carry the hashes present in the deployed HTML. A hash in the cache that is absent from the document is a leftover; the reverse is a pending failure.
  5. In Network, a request served by the worker shows (ServiceWorker) in the Size column. If hashed assets show a transfer size instead, your route pattern is not matching.

Shell

# 1. The worker script must not be cacheable.
curl -sI https://example.com/sw.js | grep -i 'cache-control\|content-type\|age'
# expect: cache-control: no-cache, max-age=0, must-revalidate
# expect: content-type: text/javascript

# 2. The build id inside the worker must match the deployed commit.
curl -s https://example.com/sw.js | grep -o "BUILD_ID = '[^']*'"

# 3. Every URL in the precache manifest must resolve at the origin.
curl -s https://example.com/sw.js \
  | grep -o '"/assets/[^"]*"' \
  | tr -d '"' \
  | while read -r path; do
      code="$(curl -s -o /dev/null -w '%{http_code}' "https://example.com${path}")"
      [ "$code" = "200" ] || echo "MISSING ${code} ${path}"
    done

# 4. Hashed assets still carry immutable at the edge.
curl -sI https://example.com/assets/main.a3f8c1d2.js | grep -i 'cache-control\|age'

Step 3 is the check worth wiring into CI as a post-deploy smoke test. A precache manifest that references a missing file is a guaranteed install failure for every client, and it is silent — the old worker simply keeps serving, so your metrics look fine while nobody receives the new build.

Edge Cases and Known Issues

A bad worker pins clients to a dead build. If the deployed worker throws during install or activate, or serves cached HTML pointing at deleted hashes, affected clients cannot self-heal — they keep running the broken worker until an update check succeeds. This is a distinct incident class with its own playbook in forcing a service worker update after a bad deploy.

Scope is determined by the script’s path, not the registration call. A worker served from /static/sw.js can only control /static/* unless the response carries Service-Worker-Allowed: /. Bundlers that emit the worker into the hashed asset directory silently break scope — always emit it to the origin root.

Never fingerprint the worker script itself. /sw.a3f8c1d2.js changes URL every build, so the browser’s byte-comparison update check has nothing to compare against and the old registration at the old URL persists forever.

Quota eviction is not graceful. Storage pressure can evict entire caches without notifying the worker. A CacheFirst handler that assumes its precache is present will throw on a match miss unless it falls through to the network — which the handler in step 3 does deliberately.

Range requests bypass simple handlers. Media elements issue Range requests; a plain cache.match returns the full 200 response and the element stalls. Exclude media from your asset route or use workbox-range-requests.

Opaque responses cost more than they report. A cross-origin response with mode: 'no-cors' has status 0 and an opaque body; browsers pad its storage accounting, sometimes to several times its real size. Restrict cacheableResponse.statuses to [200] for third-party URLs.

navigateFallback plus code splitting is a trap. Serving a precached shell for every navigation means the document never revalidates, which is exactly how a document from build N survives into build N+3.

Non-deterministic builds churn the manifest. If two builds of identical source emit different hashes, every deploy invalidates the whole precache. That is a build reproducibility problem, and it interacts with cache key architecture the same way it does at the CDN.

Performance Impact

On repeat visits a Cache Storage hit resolves in roughly 2–8 ms on desktop and 10–30 ms on a mid-range phone — comparable to a disk cache hit, and the worker boot-up (typically 20–60 ms cold) is the dominant cost rather than the lookup. Navigation preload removes that boot-up from the critical path for navigations by starting the network request in parallel, which is worth 30–50 ms on cold starts.

First-visit cost is real and worth budgeting. A precache of 40 files totalling 800 KiB compressed downloads on install, competing with whatever the page is still loading. Registering after load moves that contention out of the critical path; keeping the precache to the true app shell keeps it small.

Steady state, the win is that a CacheFirst hashed asset never generates a conditional request, not even the one immutable was designed to eliminate — the request never leaves the renderer. For a document-heavy site the NetworkFirst document route adds no measurable latency when online, because the network response is what gets returned; the cache write happens after the response is handed to the page.

FAQ

Do I still need Cache-Control: immutable if the service worker caches assets anyway?

Yes. The two caches serve different populations. Cache Storage only helps clients that already have a registered worker in the current scope; immutable helps everyone, including first-time visitors, users on browsers where registration failed, and the CDN edge nodes in front of your origin. They are complementary layers, and the header costs nothing to keep.

Should the service worker precache HTML?

Precache one document — an offline fallback — and nothing else. Precaching the real entry points means those documents live in Cache Storage until you explicitly evict them, which is how a browser ends up requesting asset hashes from a build that no longer exists at the origin. Documents belong on a network-first route with the cache as a fallback.

What happens to Cache Storage when I purge the CDN?

Nothing. A purge invalidates edge copies only. Clients with a registered worker will keep serving whatever is in Cache Storage until the worker’s own logic evicts it, which is why cache invalidation for a service worker is code you deploy rather than an API you call. Plan the two independently.

Is clients.claim() safe without skipWaiting()?

Yes, and the pairing is often misunderstood. clients.claim() only affects clients that are currently uncontrolled — typically the very first page load after registration. Without skipWaiting, the new worker still waits its turn, so claim() cannot swap versions out from under an open tab. Calling claim() alone is a safe default; calling skipWaiting() is the decision that needs justifying.