Skip to main content
fast frontend

Service Worker Caching for Offline and Instant Navigation

7 min read Chapter 24 of 33

Service Worker Caching for Offline and Instant Navigation

The Symptom

Returning visitors to the e-commerce platform experience 1.4s LCP despite all static assets being browser-cached. The 1.4s comes from the HTML document fetch (600ms on 4G for a 9.8KB gzipped HTML response) plus CSS parsing and rendering. If the HTML were served from a local service worker cache, the fetch time would drop to under 5ms.

The Cause

Browser HTTP cache serves assets when they are requested. The browser still makes a network request for the HTML document, which returns quickly if the CDN cache is warm, but still requires a round trip. On 4G with 100ms RTT, the absolute minimum HTML fetch time is ~200ms including DNS and TLS (already established for returning visitors).

A service worker intercepts the navigation request before it reaches the network and can serve the HTML from a local cache. The round trip is eliminated entirely. Navigation appears instant.

The risk: serving stale HTML means users see outdated content. The strategy must balance freshness (serve new content when available) with speed (serve cached content instantly).

The Baseline

Returning visitor LCP breakdown:

PhaseDurationCacheable?
HTML fetch600msWith service worker
CSS parse40msAlready cached
JS parse + execute120msAlready cached
LCP image decode80msAlready cached
Rendering60msN/A
Total LCP1,400ms

If the HTML fetch is eliminated: 1,400ms - 600ms = 800ms. If the service worker serves cached HTML and starts rendering immediately while fetching fresh HTML in the background, LCP drops to 800ms for the cached content.

The Fix

// sw.ts - Service worker with stale-while-revalidate for HTML
// and cache-first for hashed assets

const CACHE_NAME = "app-cache-v1";
const PRECACHE_URLS = ["/", "/category/electronics", "/offline.html"];

// Install: precache critical routes
self.addEventListener("install", (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(PRECACHE_URLS);
    }),
  );
  // Activate immediately, do not wait for existing tabs to close
  (self as any).skipWaiting();
});

// Activate: clean up old caches
self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name)),
      );
    }),
  );
  // Take control of all existing tabs
  (self as any).clients.claim();
});

// Fetch: strategy depends on resource type
self.addEventListener("fetch", (event: FetchEvent) => {
  const url = new URL(event.request.url);

  if (event.request.mode === "navigate") {
    // HTML: stale-while-revalidate
    event.respondWith(handleNavigationRequest(event.request));
  } else if (isHashedAsset(url.pathname)) {
    // Hashed assets: cache-first (immutable)
    event.respondWith(handleCacheFirst(event.request));
  } else {
    // Everything else: network-first
    event.respondWith(handleNetworkFirst(event.request));
  }
});

function isHashedAsset(pathname: string): boolean {
  return /\.[a-f0-9]{8}\.(js|css|woff2|avif|webp|jpg|png|svg)$/.test(pathname);
}

async function handleNavigationRequest(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cachedResponse = await cache.match(request);

  // Start network fetch in background
  const networkPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => null);

  if (cachedResponse) {
    // Return cached response immediately, update cache in background
    return cachedResponse;
  }

  // No cache: wait for network
  const networkResponse = await networkPromise;
  if (networkResponse) {
    return networkResponse;
  }

  // Both cache and network failed: serve offline page
  const offlinePage = await cache.match("/offline.html");
  return offlinePage ?? new Response("Offline", { status: 503 });
}

async function handleCacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) return cached;

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

async function handleNetworkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached ?? new Response("Offline", { status: 503 });
  }
}

Register the service worker from the main application:

// register-sw.ts
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js")
      .then((registration) => {
        // Check for updates every 60 seconds
        setInterval(() => {
          registration.update();
        }, 60_000);
      })
      .catch((error) => {
        console.error("SW registration failed:", error);
      });
  });
}

The Vite build produces the service worker as a separate output:

// vite.config.ts addition
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: "src/main.ts",
        sw: "src/sw.ts",
      },
      output: {
        entryFileNames: (chunkInfo) => {
          return chunkInfo.name === "sw" ? "sw.js" : "assets/[name]-[hash].js";
        },
      },
    },
  },
});

The service worker file must be served at the root (/sw.js) without a content hash because the browser uses a byte comparison of the service worker script to detect updates. If the filename changed on every build, the browser would have no way to compare the new version against the installed version.

The Proof

Returning visitor metrics with service worker:

MetricWithout SWWith SWDelta
HTML fetch time600ms2ms (from SW cache)-598ms
LCP (p75)1,400ms820ms-580ms
Navigation feelNetwork-dependentInstantQualitative

The LCP dropped from 1,400ms to 820ms. The remaining 820ms is CSS parsing, JavaScript execution, image decoding, and rendering, none of which require network requests for a returning visitor.

Content freshness: the stale-while-revalidate pattern means users see the cached HTML immediately and the service worker fetches the latest HTML in the background. If the user navigates to the same page again within the session, they see the updated content. For content that changes infrequently (product listings, editorial pages), the staleness is measured in minutes and is invisible to users.

The CI Lighthouse gate from Chapter 2 does not test with a service worker (Lighthouse clears service workers before testing). This is intentional: the CI gate validates first-visit performance, which is the worst case. Service worker improvements benefit returning visitors, who already have better performance from browser caching.

The Trade-off

Service workers add complexity to the debugging experience. When a user reports seeing stale content, the first troubleshooting step is “clear the service worker.” DevTools > Application > Service Workers > Unregister. Training support staff and developers on this workflow is an ongoing cost.

The precache list (PRECACHE_URLS) must be maintained. Adding a new critical route without updating the precache list means returning visitors do not get instant navigation to that route until they have visited it once. Generating the precache list from the build output is more reliable than maintaining it manually.

Service worker cache storage has size limits that vary by browser and are reduced on iOS. On Safari, the total storage quota for a domain (including IndexedDB, service worker caches, and other storage) is approximately 50MB. For the e-commerce platform with 48 product images per listing page, aggressive image caching would exhaust this quota quickly. The service worker caches only HTML, CSS, JS, and fonts, totaling approximately 800KB.

The skipWaiting() and clients.claim() calls in the install and activate handlers cause the new service worker to take control immediately. This can cause issues if the new service worker’s cache strategy is incompatible with the currently loaded page. For the e-commerce platform, the stale-while-revalidate strategy is inherently compatible with any version of the HTML, so immediate activation is safe. For applications where the service worker cache format changes between versions, a more careful activation strategy is needed.