Stale-While-Revalidate for the App Shell

After reading this you will be able to serve your single-page app’s shell from the cache on the very first frame while a background fetch quietly updates it — giving users instant navigations without freezing them on a stale build.

← Back to Service Worker Routing Strategies

Prerequisites

Core Concept

Stale-while-revalidate (SWR) resolves the tension that forces a hard choice between cache-first and network-first: it takes cache-first’s instant paint and network-first’s freshness and runs them in parallel. On each request it returns the cached response immediately — the “stale” half — and, in the background, fetches a fresh copy and writes it into the cache — the “revalidate” half. The user never waits on the network, yet the cache converges on the current version, so the next navigation is fresh. The only cost is a one-navigation lag: a change deployed now is served on the visit after this one.

That trade is close to ideal for the app shell of a SPA. The shell is the minimal HTML, CSS, and bootstrapping JavaScript that renders the frame — header, navigation chrome, an empty mount point — before the application fetches its real content. It carries no per-request data, so serving a slightly older shell is harmless; the dynamic content it loads afterwards is fetched separately and can use its own, fresher strategy. Because the shell changes only when you redeploy, the revalidation almost always writes back an identical response, and on the rare deploy it silently rolls users forward one navigation later.

SWR only makes sense for resources where a brief staleness window is acceptable and instant paint is valuable: the shell, images, avatars, non-critical widgets. It is the wrong choice for anything that must be correct this instant — a bank balance, a checkout total, an auth token — where the one-navigation lag is a bug, not a feature.

Implementation

The strategy is compact: open the cache, kick off the network fetch that writes back on success, and return the cached response if there is one, otherwise wait on the network. The subtlety is making the background fetch robust so a failed revalidation never rejects the response the user already has.

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

const SHELL_CACHE = "shell-v3";
const SHELL_URL = "/index.html"; // the precached app shell document

async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(req);

  // Background revalidation — must never reject the user-facing response.
  const revalidation = fetch(req)
    .then((res) => {
      if (res.ok) cache.put(req, res.clone()); // refresh for next time
      return res;
    })
    .catch(() => undefined); // offline: keep serving the cached shell

  // Serve cache instantly when present; otherwise fall back to the network.
  return cached ?? (await revalidation) ?? Response.error();
}

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

  // Route every navigation to the precached shell, served SWR-style.
  if (req.mode === "navigate") {
    const shellRequest = new Request(SHELL_URL);
    event.respondWith(staleWhileRevalidate(shellRequest, SHELL_CACHE));
    return;
  }
});

Because navigations are rewritten to a single canonical SHELL_URL, every route in the SPA paints the same cached shell instantly, and the client-side router takes over once the JavaScript boots. To signal a fresh shell has landed, post a message from the revalidation branch so the page can prompt a reload:

// TypeScript 5.x — inside the .then() of the revalidation fetch
if (res.ok) {
  await cache.put(req, res.clone());
  const clients = await self.clients.matchAll();
  for (const client of clients) client.postMessage({ type: "shell-updated" });
}

Verification

Prove that the first paint comes from the cache and that a background revalidation actually refreshes it. Playwright can assert the cached response renders offline; DevTools’ Network panel shows the paired background request marked (ServiceWorker).

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("shell paints from cache and revalidates in the background", async ({ page, context }) => {
  await page.goto("/");
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  // Offline: the cached shell still paints instantly.
  await context.setOffline(true);
  await page.goto("/some/deep/route");
  await expect(page.locator("#app-shell")).toBeVisible();

  // Back online: a fresh navigation triggers the revalidation write-back.
  await context.setOffline(false);
  const updated = page.waitForEvent("console", (m) => m.text() === "shell-updated");
  await page.reload();
  await updated;
});

Gotchas

  • Applying SWR to dynamic content. The one-navigation staleness window is fine for the shell but wrong for live data; route API reads through network-first, not SWR, or users act on yesterday’s numbers.
  • A rejected revalidation killing the response. If the background fetch can reject and you await it before the cache hit, an offline user gets an error instead of the cached shell. Catch the revalidation and only fall through to it when the cache misses.
  • No update signal to the page. Because SWR is silent, a user can sit on a stale shell indefinitely; post a shell-updated message so long-lived sessions can offer a refresh.
  • Revalidating on every sub-resource needlessly. Doubling every request into a background fetch wastes bandwidth on assets that are content-hashed and immutable — send those through cache-first and reserve SWR for the shell and mutable assets.

FAQ

What exactly is the app shell in stale-while-revalidate? It is the minimal, content-agnostic HTML, CSS, and JavaScript that renders your application’s frame and mount point before any real data loads. Because it changes only on redeploy, serving a marginally older copy is harmless, which is what makes it a good fit for a strategy that trades one navigation of freshness for instant paint.

How stale can the shell actually get with SWR? At most one navigation behind. The moment a request is served from cache, a background fetch refreshes that cache entry, so the following navigation gets the updated shell. A user on a single long session sees the change on their next full page load unless you prompt a reload via a client message.

When should I choose network-first over stale-while-revalidate for navigations? When the navigation document carries per-request server-rendered content that must be current, freshness beats paint speed and network-first is correct. SWR suits a static shell whose real content is fetched client-side; the cache-first vs network-first guide walks through the decision.

Does the background revalidation slow down the current navigation? No — it runs after the cached response is already returned, off the critical path. The user’s paint depends only on the cache read; the network fetch resolves whenever it resolves and simply updates storage for next time.

How do I tell users a new shell is available? Have the revalidation branch postMessage to controlled clients when it writes a genuinely new response, and let the page show an unobtrusive prompt to reload. This keeps SWR’s instant-paint benefit while giving users an explicit path to the latest build.