Service Worker Caching Strategies

Implementing deterministic offline state persistence requires precise orchestration of the CacheStorage API, fetch interception, and service worker lifecycle management. For frontend engineers and PWA developers building offline-first architectures, the caching strategy you pick directly shapes perceived performance, data consistency, and cross-browser reliability. This guide details production-ready patterns for service worker caching, emphasizing API precision, quota management, and fallback routing as part of the wider Offline Sync Strategies & Background Workflows approach. The single most important decision — when to serve from cache first and when to hit the network first — gets its own deep dive in Stale-While-Revalidate vs Network-First.

Stale-while-revalidate fetch interception flow A flow showing a fetch event served instantly from cache while a parallel network request revalidates and updates the cache in the background. fetch event GET request CacheStorage match, respond now Network fetch parallel, background cache.put() clone, update entry

Choosing a strategy

The CacheStorage API gives you a key-value store of Request/Response pairs, but it imposes no policy — the policy is the order in which you consult the cache and the network inside the fetch handler. The four canonical strategies below cover almost every asset class, and the full comparison of the two most common ones is in Stale-While-Revalidate vs Network-First.

Strategy First byte from Freshness Works offline Best for
Cache-first Cache Stale until purged Yes Hashed static assets, fonts
Network-first Network Always fresh when online Falls back to cache Auth-sensitive JSON, dashboards
Stale-while-revalidate Cache One revision behind Yes App shell, tolerant API reads
Network-only Network Always fresh No Analytics, non-cacheable POSTs

Cache-first is fastest but serves whatever is stored until you explicitly invalidate it, so it suits content-addressed assets whose URL changes on every deploy. Network-first guarantees freshness when online and degrades to the cache offline, which fits data the user must not see stale. Stale-while-revalidate (SWR) splits the difference: it answers instantly from cache and refreshes the entry in the background, accepting that the user sees data exactly one revision old. Pick per route, not per app.

1. Setup & cache initialization

Deterministic cache invalidation begins with a strict namespace and versioning schema. Avoid generic keys like app-cache; use semantic prefixes that reflect asset type and deployment iteration (for example sw-static-v3.2.1, sw-api-v1). This guarantees that stale assets are isolated and safely discarded during activation.

// sw.js
const CACHE_VERSION = 'v3.2.1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/icons/favicon.svg',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(ASSETS))
  );
});

// Activate: delete stale cache versions and claim open clients immediately.
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => name !== STATIC_CACHE)
          .map((name) => caches.delete(name))
      )
    ).then(() => self.clients.claim())
  );
});

Register the worker with explicit scope boundaries to prevent unintended route interception. Use navigator.serviceWorker.register('/sw.js', { scope: '/' }) for root-level control, or restrict to /app/ for modular deployments. During install, cache.addAll() executes atomically: if a single asset fails to fetch, the entire cache creation fails, preventing partial hydration states.

Aligning initial asset pre-caching with the broader Offline Sync Strategies & Background Workflows approach ensures deterministic state hydration across navigation cycles. This matters most when transitioning from a cached shell to dynamic data fetching on mobile networks, where connection instability is frequent.

2. Implementation: stale-while-revalidate with async fallback

The SWR pattern delivers instant cached responses while asynchronously updating the cache in the background. It is ideal for static assets, API endpoints with eventual consistency, and content that tolerates minor staleness.

self.addEventListener('fetch', (event) => {
  // Bypass non-GET requests (POST, PUT, DELETE).
  if (event.request.method !== 'GET') return;

  event.respondWith(
    (async () => {
      const cache = await caches.open(STATIC_CACHE);
      const cachedResponse = await cache.match(event.request);

      // Parallel network fetch for background revalidation.
      const networkFetch = fetch(event.request)
        .then(async (res) => {
          if (res.ok) {
            // Clone required: a response body is a single-use stream.
            await cache.put(event.request, res.clone());
          }
          return res;
        })
        .catch(() => null); // Graceful degradation on network failure.

      // Return cached immediately; fall back to network on cache miss.
      const response = cachedResponse || (await networkFetch);

      // Static offline fallback if both cache and network fail.
      if (!response) {
        return new Response('Offline fallback content', {
          headers: { 'Content-Type': 'text/html' },
        });
      }

      return response;
    })()
  );
});

Implementation notes:

3. Edge cases & storage boundaries

CacheStorage runs under strict browser-imposed quotas, sharing the origin’s storage pool with IndexedDB. Unbounded caching triggers QuotaExceededError during cache.put(), silently dropping updates or crashing the worker if unhandled.

Proactive quota management:

async function handleQuotaExceeded(request, response) {
  const cache = await caches.open(STATIC_CACHE);
  const keys = await cache.keys();

  // Simple LRU-style eviction: remove the oldest entries until space is freed.
  for (const key of keys.slice(0, 3)) {
    await cache.delete(key);
  }

  // Retry the put after eviction.
  try {
    await cache.put(request, response);
  } catch (err) {
    console.error('Cache put failed even after eviction:', err);
  }
}

Race condition mitigation. Concurrent navigation or rapid tab refreshes can trigger duplicate fetch interceptions. Use a request-deduplication map keyed by event.request.url to short-circuit redundant network calls and prevent simultaneous writes to the same cache key.

HTTP headers vs CacheStorage. Unlike the HTTP cache, CacheStorage ignores Cache-Control, Expires, and ETag headers. The worker must validate freshness manually using custom metadata or a timestamp header. When merging cached state with incoming network deltas — common with optimistic writes — apply deterministic Conflict Resolution Algorithms to prevent corruption, and coordinate the user-facing side of those writes with Optimistic UI Updates & Rollback.

4. Browser compatibility

Service workers and CacheStorage are broadly supported, but the behavioral edges differ enough to matter in production.

Capability Chrome Firefox Safari Edge
Service workers + CacheStorage Yes Yes 11.1+ Yes
Async respondWith() body Yes Yes Yes (strict event-loop timing) Yes
self.skipWaiting() / clients.claim() Yes Yes Yes Yes
navigator.storage.estimate() Yes Yes Partial / unreliable Yes
SW persistence under ITP n/a n/a Cleared after 7 days inactivity n/a

On iOS Safari 16 and 17, the CacheStorage pool is subject to Intelligent Tracking Prevention: a PWA the user has not installed to the Home Screen can have its caches wiped after roughly seven days of inactivity. Never treat the cache as durable on iOS; design the app to re-hydrate from the network on the next launch. Safari also reports unreliable numbers from navigator.storage.estimate(), so prefer reactive eviction on QuotaExceededError over proactive quota math there.

5. Debugging & production telemetry

Production service workers need observable telemetry to diagnose cache drift, fallback triggers, and activation failures.

By enforcing strict versioning, handling quota boundaries gracefully, and instrumenting lifecycle events, teams ship resilient service worker caching that scales across modern browsers and unstable networks.

Frequently Asked Questions

When should I use stale-while-revalidate instead of network-first?

Use stale-while-revalidate when an instant response matters more than perfect freshness and the user can tolerate seeing data one revision old — app shells, avatars, tolerant API reads. Use network-first when stale data is unacceptable, such as account balances or anything authorization-sensitive. The full decision is laid out in Stale-While-Revalidate vs Network-First.

Why must I clone the response before caching it?

A Fetch Response body is a single-use stream. If you pass the original response to cache.put(), its body is consumed and the client receives an empty stream, and vice versa. Calling res.clone() before caching gives you two independent readable bodies — one for the cache and one for the page.

Does CacheStorage respect Cache-Control headers?

No. Unlike the browser HTTP cache, CacheStorage stores exactly what you put in it and ignores Cache-Control, Expires, and ETag. You are responsible for expiry: stamp entries with a timestamp in custom metadata or a header and validate freshness yourself in the fetch handler.

How do I avoid QuotaExceededError when the cache grows?

Wrap cache.put() in a try/catch and, on QuotaExceededError, evict the oldest entries before retrying. CacheStorage shares the origin quota with IndexedDB, so an aggressive cache can starve your sync data. Pair this with Conflict Resolution Algorithms so evicted-then-refetched data still merges cleanly.

Related