Stale-While-Revalidate for Shared Content

This guide is part of Service Worker Caching Strategies for Share PWAs, which itself is part of Offline-First PWA Patterns for Web Share API.

Stale-while-revalidate (SWR) answers a request from the cache immediately, then quietly fetches a fresh copy in the background to serve next time. For share metadata — link previews, thumbnails, and the list of items a user has shared — it is close to ideal: the UI paints instantly from cache while the newer data arrives invisibly. This guide implements SWR by hand in the fetch handler, since the browser’s HTTP-header SWR does not apply inside a service worker, and marks the exact cases where SWR quietly serves wrong data.

Feature Detection Gate

SWR lives entirely in the service worker, so the only gate is that a worker is registered in a secure context. Do the check in the foreground before you rely on any cached metadata path.

// swr-support.js
export function isRuntimeCachingAvailable() {
  return (
    window.isSecureContext === true &&
    'serviceWorker' in navigator &&
    'caches' in window
  );
}

If this returns false, treat every metadata read as a live network call — there is no cache to fall back on, so your UI must tolerate a first-load spinner in that environment.

Solution Walkthrough

Step 1 — Match the cache first

Open the runtime cache and look the request up. Whatever is there is what you will return to the page immediately; a miss means this is a first visit for this URL.

// swr.js
const RUNTIME_CACHE = 'share-runtime-v7';

export async function staleWhileRevalidate(event) {
  const cache = await caches.open(RUNTIME_CACHE);
  const cached = await cache.match(event.request);
  // `cached` is served now; the network refresh happens in Step 2.
  const networkPromise = revalidate(cache, event);
  return cached || networkPromise;
}

Returning cached || networkPromise is the whole SWR contract: if there is a cached copy, the page never waits on the network; if there is not, it awaits the fetch so a first visit still resolves.

Step 2 — Revalidate in the background

Kick off the fetch, cache only successful responses, and keep the worker alive with event.waitUntil so the update finishes even after the response has been returned. Clone before caching — a body can be read only once.

// swr.js (continued)
async function revalidate(cache, event) {
  const networkFetch = (async () => {
    try {
      const response = await fetch(event.request);
      if (response.ok) {
        await cache.put(event.request, response.clone());
        await trimCache(cache, 50); // bound growth — Step 3 caveat
      }
      return response;
    } catch {
      // Offline or server error: fall back to whatever we already cached.
      return cache.match(event.request);
    }
  })();

  event.waitUntil(networkFetch);
  return networkFetch;
}

The response.ok check is what stops a 500 error page from overwriting good cached metadata. Without it, one bad deploy poisons the cache and SWR then serves that error to every subsequent reader.

Step 3 — Wire it into the fetch handler and bound the cache

Route only genuine share-metadata requests to SWR, and give the cache an eviction ceiling so a per-item preview endpoint cannot grow forever.

// sw.js
import { staleWhileRevalidate } from './swr.js';

self.addEventListener('fetch', function onFetch(event) {
  if (event.request.method !== 'GET') return; // never SWR a share POST
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/share-preview') ||
      url.pathname.startsWith('/api/shared-items')) {
    event.respondWith(staleWhileRevalidate(event));
  }
});

export async function trimCache(cache, maxEntries) {
  const keys = await cache.keys();
  if (keys.length <= maxEntries) return;
  // keys() returns insertion order — delete the oldest overflow.
  const overflow = keys.length - maxEntries;
  for (let i = 0; i < overflow; i += 1) {
    await cache.delete(keys[i]);
  }
}

The method !== 'GET' guard matters here specifically: a share target POST must never reach cache.put, which would throw. SWR is strictly a read strategy for GET metadata; the durable inbound-share payload belongs in an IndexedDB share queue, never the response cache.

When SWR Is Right — and When It Is Not

SWR trades correctness for speed on every read. That trade is a win for share metadata that is cheap to be slightly wrong about: a link preview whose title updated an hour ago, a shared-items list that is one entry behind. It is a loss the instant the user makes a decision based on the value. Serving a stale “share delivered” status, a stale auth token, or a stale price means the user acts on data you already know is old. For those, use the network-first branch from the caching strategies guide so the read reflects the server, or cache-only if the value is immutable once written.

Failure Modes and Recovery

Symptom Root cause Minimal fix
Stale preview shown forever Revalidation keeps failing; no expiry in SWR Cache only response.ok; store a timestamp and treat entries past a max age as a miss
Runtime cache grows unbounded A new entry written per unique URL Call trimCache after every cache.put (Step 3)
TypeError: body already used Original response cached and returned Cache response.clone(), return the original
Error page served from cache A non-200 response was cached Gate cache.put behind if (response.ok)
Two tabs race, older data wins Concurrent match/put overlap Accept last-write-wins for metadata; use network-first if order matters
Share POST throws in handler POST routed into SWR Early-return on request.method !== 'GET'

The dominant failure is serving stale forever: because hand-rolled SWR has no built-in freshness check, a broken endpoint that always rejects means the cached copy is returned indefinitely with no signal to the user. The timestamp-plus-max-age guard converts that silent staleness into a normal cache miss, at which point the network path (and its error handling) takes over.

Browser and Platform Caveat

Hand-implemented SWR runs anywhere the Cache API and service workers do, which today is Chromium, Firefox, and Safari 16.4+. Do not confuse it with the HTTP Cache-Control: stale-while-revalidate directive — that governs the browser’s own HTTP cache and has no effect on responses you store in the Cache API, which the worker serves verbatim regardless of headers. On iOS Safari the extra caveat is eviction: WebKit may purge the runtime cache under storage pressure, so SWR must always tolerate a cold miss and fall back to the network rather than assuming the previous value is still present.

FAQ

When is stale-while-revalidate the wrong choice for share data?

Whenever the user acts on the value at read time and staleness causes a wrong action: authentication state, a share’s delivery or read status, prices, or inventory counts. SWR always renders the previous value first, so the user sees and acts on stale data before the background revalidation lands. Use network-first or cache-only from the caching strategies guide for correctness-critical reads.

Why must I clone the response before caching it?

A Response body is a stream that can be read only once. If you return the original to the page and also pass it to cache.put, one consumer gets an already-consumed body and throws. Call response.clone() and cache the clone so the page receives an intact, readable response.

What happens if revalidation always fails?

The cached copy is served forever with no natural expiry, because SWR has no freshness check of its own. Guard against it by only caching response.ok results, adding a stored timestamp and treating entries past a max age as a miss, and surfacing a soft error if the background fetch has failed repeatedly.

How do I stop the SWR cache growing without bound?

SWR writes a fresh entry on every unique request, so a per-item metadata endpoint accumulates entries. Cap it with an LRU-style trim in the background step: after cache.put, read cache.keys() and delete the oldest entries beyond a fixed limit, as trimCache does in Step 3.