Cache API for Static Assets: Production-Grade Offline Delivery

Serving CSS, JavaScript bundles, fonts, and images instantly while offline is the defining capability of a Progressive Web App, and the Cache API is the primitive that makes it possible. When it is missing or misconfigured, the symptoms are unmistakable: a white screen the moment the network drops, a spinner that never resolves on the subway, or a version-skewed app that serves last week’s CSS against this week’s markup. Unlike key-value stores, the Cache API operates at the network layer — a service worker intercepts fetch() and returns cached Response objects directly, with no serialization tax. This guide is part of Browser Storage Fundamentals & Quotas and assumes you have read Understanding Web Storage APIs; it focuses on the production concerns — precache error boundaries, quota awareness, cross-engine eviction, lifecycle timing, and cache hygiene — that separate a demo from a resilient offline experience.

Service worker cache-first with stale-while-revalidate flow A flow diagram showing a fetch event checking the cache first, returning a hit immediately while revalidating in the background, or falling back to the network and a 503 offline response on a miss. fetch event cache.match() lookup Hit → return now revalidate in background Miss → network 3 s timeout cache.put(clone) store for next time 503 offline graceful fallback Cache API stores Request/Response pairs — no JSON serialization

Architectural Positioning & Storage Boundaries

When architecting offline-first applications, selecting the right persistence layer is critical. While developers often default to key-value stores, the synchronous APIs covered in localStorage vs sessionStorage introduce main-thread blocking that degrades mobile web performance and violates modern Core Web Vitals thresholds. For static asset delivery, the Cache API provides a dedicated, asynchronous HTTP response store optimized for service worker interception.

The Cache API excels at storing immutable assets like CSS, JS bundles, and image sprites, but knowing When to Use the Cache API over IndexedDB prevents architectural mismatches when handling structured application state. Unlike IndexedDB, the Cache API operates at the network layer, intercepting fetch() requests and returning cached Response objects directly. This design eliminates serialization overhead and enables instant offline routing. However, it operates within strict origin-level quotas, requiring developers to monitor storage boundaries and implement proactive eviction strategies before hitting browser-enforced limits.

Concern Cache API IndexedDB
Stored unit Request / Response pairs Structured JS objects (structured clone)
Ideal payload HTML, CSS, JS, fonts, images JSON state, relational records, queues
Serialization cost None (stores raw streams) Structured clone on every write/read
Query model Match by request/URL Indexes, cursors, key ranges
Service worker fit Native (fetch interception) Avoid in the fetch path

For JSON payloads specifically, the trade-off is subtle enough to warrant its own analysis in IndexedDB vs Cache API for Offline JSON Payloads.

The Cache API Surface: Methods, Parameters & Return Values

The Cache API is exposed through two objects. caches — the global CacheStorage instance, available on both window and self (the service worker global scope) — manages named caches. Each named cache is a Cache object that holds Request/Response entries. Every method returns a promise; there is no synchronous variant, which is what keeps the API off the main thread.

Method Signature Returns Notes
caches.open open(cacheName: string) Promise<Cache> Creates the cache if it does not exist; never rejects on a missing name
caches.keys keys() Promise<string[]> Lists all cache names for the origin — the basis of version pruning
caches.match match(request, options?) Promise<Response | undefined> Searches every cache; prefer a scoped cache.match() in the hot path
caches.delete delete(cacheName: string) Promise<boolean> Resolves false if the named cache did not exist
cache.put put(request, response) Promise<void> Stores one pair; rejects with QuotaExceededError when full
cache.add / cache.addAll addAll(requests: RequestInfo[]) Promise<void> Fetches and stores atomically — one failure rejects the whole batch
cache.match match(request, options?) Promise<Response | undefined> Scoped lookup within a single cache
cache.delete delete(request, options?) Promise<boolean> Removes one entry; supports ignoreSearch and ignoreVary
cache.keys keys(request?, options?) Promise<readonly Request[]> Enumerates stored requests for pruning and audits

The options bag on match, delete, and keys is a CacheQueryOptions object: ignoreSearch (ignore the query string when matching), ignoreMethod (match regardless of HTTP method), and ignoreVary (ignore the response’s Vary header). ignoreVary matters more than it looks: without it, a response stored under a Vary: Accept-Encoding header will silently miss when the runtime request advertises a different encoding, producing phantom cache misses that only reproduce on some devices.

// Minimal shape of the surface you will actually use in a worker
declare const caches: CacheStorage;

async function readAsset(url: string): Promise<Response | undefined> {
  const cache = await caches.open('static-v1.4.2');
  // ignoreSearch strips cache-busting query params so ?v=hash still hits
  return cache.match(url, { ignoreSearch: true });
}

Two constraints trip people up. First, cache.put() rejects if the Response has a non-200 status (opaque cross-origin responses use status 0 and are the exception — they store, but you cannot inspect them). Second, a Response body is a one-shot stream: once it has been read (by response.json(), response.text(), or passed to put()), it is consumed. Any time you both store and return a response, you must clone() it first.

Core Implementation: Async Caching & Error Boundaries

Implementing robust async error handling with try/catch around caches.open() and cache.addAll() is mandatory, as network failures during cache population can leave the application in a partially cached state. Modern implementations avoid cache.addAll() for critical bundles due to its atomic failure behavior; instead, they wrap individual cache.put() calls in Promise.allSettled() to isolate failures, log telemetry, and guarantee service worker activation even on degraded networks.

// sw.ts - Production-grade precache with explicit error boundaries
const STATIC_CACHE_NAME = 'static-v1.4.2';
const CRITICAL_ASSETS = [
  '/assets/css/main.css',
  '/assets/js/app.bundle.js',
  '/assets/js/vendor.bundle.js',
  '/favicon.ico',
];

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(STATIC_CACHE_NAME);

      // Use Promise.allSettled() to prevent a single 404 from aborting installation
      const results = await Promise.allSettled(
        CRITICAL_ASSETS.map(async (url) => {
          const response = await fetch(url, { cache: 'no-store' });
          if (!response.ok)
            throw new Error(`Fetch failed: ${url} (${response.status})`);
          await cache.put(url, response);
        })
      );

      // Telemetry: log failures without blocking installation
      const failures = results.filter((r) => r.status === 'rejected');
      if (failures.length > 0) {
        console.warn(
          '[Cache API] Partial precache failure:',
          failures.map((f) => (f as PromiseRejectedResult).reason)
        );
        // In production, send to analytics/telemetry endpoint
      }
    })()
  );
});

When intercepting requests, cache.match() should be paired with a network fallback to implement a cache-first with stale-while-revalidate strategy. This ensures instant load times for returning users while silently updating assets in the background. The broader catalog of strategies — cache-first, network-first, and stale-while-revalidate — is covered in Service Worker Caching Strategies.

Concurrency & Lifecycle Considerations

The Cache API itself imposes no locks and offers no transactions — each put/match/delete resolves independently, and concurrent writes to the same key are last-write-wins. That simplicity shifts the correctness burden onto the service worker lifecycle. The two events that bracket cache population, install and activate, run at very specific moments, and getting the timing wrong is the most common source of “my new deploy still serves old assets” bugs.

Service worker lifecycle timeline for cache versioning A left-to-right timeline of the service worker lifecycle: install (waitUntil holds until the precache resolves), waiting (the old worker still controls open pages), activate (waitUntil holds until stale caches are pruned), and controlling (clients.claim() takes over open tabs). A dashed arc shows self.skipWaiting() short-circuiting the waiting phase. self.skipWaiting() short-circuits waiting install precache the shell waiting old worker in control activate prune stale caches controlling serves new version event.waitUntil() holds until precache resolves event.waitUntil() holds until pruning resolves self.clients.claim() takes over open tabs

Three rules keep versioning deterministic:

  1. Wrap every cache mutation in event.waitUntil(). The browser is free to terminate an idle worker the instant its event handler returns. waitUntil() extends the event’s lifetime until the promise settles, so an unwrapped caches.open(...).then(...) can be killed mid-write, leaving a half-populated cache.
  2. Version the cache name, never mutate in place. Deploying static-v1.4.3 alongside the old static-v1.4.2 means the new worker precaches into a fresh namespace while the old worker keeps serving pages from the old one. Only after activate prunes the old name does the switch become visible — an atomic cutover with no version skew.
  3. Understand skipWaiting() and clients.claim(). By default a new worker waits until every tab controlled by the old worker closes. self.skipWaiting() in install promotes it immediately, and self.clients.claim() in activate makes it take control of already-open pages. Use them together deliberately: they can swap the CSS under a running page, so pair them with a client-side “new version available — reload” prompt rather than forcing a silent asset swap.

Cross-tab behavior is where the Cache API meets the rest of your storage stack. A single service worker instance backs every tab of an origin, so there is no per-tab cache — all tabs read and write the same named caches. That is usually what you want for static assets, but when a background job in one tab needs to rebuild a cache without a second tab reading a half-written state, the Cache API gives you no mutual exclusion. Coordinate those writes with the Web Locks API for Cross-Tab Coordination, acquiring a named lock around the rebuild so concurrent tabs serialize instead of racing.

Cross-Browser Quirks, Quota & Retry Strategy

Before implementing, understand how browsers partition storage origins and enforce eviction thresholds — the mechanics live in Storage Quotas & Eviction Policies. The Cache API’s behavior diverges significantly across rendering engines:

  1. Safari (WebKit): Aggressively evicts background tab caches using strict LRU policies. If a PWA remains in the background for more than 7 days without the user visiting, Safari may purge the entire cache. Critical route assets must be backed by an IndexedDB manifest for guaranteed recovery.
  2. Firefox (Gecko): Enforces strict atomicity on cache.addAll(). A single 404, CORS violation, or quota breach aborts the entire batch operation. Always validate asset availability and use individual cache.put() calls for resilience.
  3. Chromium (Blink): Generally the most permissive; dynamically allocates quota up to ~60% of available disk space. Still requires explicit error handling for quota boundaries on constrained devices.

Quota awareness must be baked into the caching lifecycle. Use navigator.storage.estimate() to proactively gauge available space before populating caches, and treat a QuotaExceededError as a signal to prune-and-retry rather than a terminal failure:

async function checkStorageQuota(): Promise<{
  usage: number;
  quota: number;
  available: number;
}> {
  if (!navigator.storage?.estimate)
    return { usage: 0, quota: Infinity, available: Infinity };

  const { usage = 0, quota = Infinity } = await navigator.storage.estimate();
  const available = quota - usage;
  return { usage, quota, available };
}

// Adaptive put with a single prune-and-retry on quota exhaustion
async function safeCachePut(
  cache: Cache,
  url: string,
  response: Response,
  prune: () => Promise<void>
): Promise<void> {
  const { available } = await checkStorageQuota();
  const estimatedSize =
    parseInt(response.headers.get('Content-Length') || '0', 10) || 50000;

  const attemptPut = () => cache.put(url, response.clone());

  if (available < estimatedSize * 1.5) await prune(); // pre-emptive headroom

  try {
    await attemptPut();
  } catch (err) {
    if ((err as DOMException).name !== 'QuotaExceededError') throw err;
    await prune(); // reclaim space, then retry exactly once
    await attemptPut();
  }
}

Retry is deliberately bounded to one attempt: an unbounded loop against a full disk becomes a busy-wait that drains battery and never succeeds. If the second put() still throws, surface the failure to telemetry and fall through to a network-only path for that asset.

Browser Compatibility

Engine Cache API navigator.storage.estimate() cache.addAll() atomicity Known caveats
Chrome / Edge (Blink) Full Yes Atomic Up to ~60% of free disk; permissive eviction
Firefox (Gecko) Full Yes Strict — one failure aborts batch Prefer individual cache.put() for resilience
Safari 16/17 (WebKit) Full Partial (coarse quota) Atomic ITP purges caches after 7 days idle; LRU on background tabs
Android WebView Full Yes Atomic Per-app quota shared across WebViews

On iOS Safari 16 and 17, the seven-day Intelligent Tracking Prevention window is the dominant failure mode: a backgrounded PWA can have its entire cache purged, so any route that must work offline needs an IndexedDB manifest to detect the loss and re-precache on next launch. Safari’s estimate() is also intentionally coarse — it reports a rounded, quantized quota to resist storage-fingerprinting, so never treat its numbers as byte-accurate budgeting.

Performance & Scale Considerations

The Cache API is fast because it never serializes, but three costs still show up under load.

Response cloning. Every stale-while-revalidate hit clones the response — once to return, once to store. clone() is cheap for small assets but tees the underlying stream, so a large body is buffered until both branches drain. For multi-megabyte assets (video posters, WASM bundles), read from the cache and skip the background revalidation on constrained devices to avoid holding two copies in memory at once.

caches.match() vs scoped cache.match(). The global caches.match() walks every named cache in insertion order until it finds a hit. With a handful of versioned caches that is negligible; with dozens of per-user or per-locale caches it becomes a linear scan on every request. In the fetch hot path, open the one cache you need and call cache.match() on it directly.

Precache batch size. install blocks activation until waitUntil resolves, so precaching hundreds of assets on a slow connection delays the worker taking control. Precache only the critical shell — the CSS, the entry bundles, the app-frame HTML — and let runtime caching populate the remaining images and secondary routes on first use. A 15-file critical set installs in well under a second; a 400-file “cache everything” list can stall install for tens of seconds on 3G and time out entirely under Firefox’s atomic addAll.

Lever Cheap default Scales poorly when Mitigation
Response cloning Fine for shell assets Bodies are multi-MB Skip background revalidate on large payloads
Cache count 1–3 versioned caches Dozens of per-user caches Scope cache.match(); consolidate names
Precache list 10–20 critical files Hundreds of assets at install Precache shell only; runtime-cache the rest

Production Fallbacks & Cache Hygiene

Production fallbacks should include a network-first strategy for critical API routes and a graceful degradation to a static offline response when local storage limits are reached. When quota limits are breached, implement selective asset pruning by MIME type priority: application/javascript > text/css > image/* > font/*.

// sw.ts - Fetch event with timeout & fallback
self.addEventListener('fetch', (event: FetchEvent) => {
  if (event.request.method !== 'GET') return;

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

      // Cache-first with stale-while-revalidate
      if (cachedResponse) {
        // Fire-and-forget network update
        fetch(event.request)
          .then((networkRes) => {
            if (networkRes.ok) cache.put(event.request, networkRes.clone());
          })
          .catch(() => {});
        return cachedResponse;
      }

      // Network fallback with 3 s timeout
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 3000);

      try {
        const networkRes = await fetch(event.request, {
          signal: controller.signal,
        });
        clearTimeout(timeoutId);
        if (networkRes.ok) {
          await cache.put(event.request, networkRes.clone());
          return networkRes;
        }
      } catch {
        clearTimeout(timeoutId);
      }

      return new Response('Asset unavailable offline', { status: 503 });
    })()
  );
});

Maintaining cache hygiene requires automated cleanup routines during the service worker activate event. Delete all caches whose names do not match the current STATIC_CACHE_NAME, then call self.clients.claim() so the new worker takes control of existing pages immediately.

// sw.ts - Activate: prune stale cache versions and take control
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(
        keys
          .filter((name) => name !== STATIC_CACHE_NAME)
          .map((name) => caches.delete(name))
      );
      await self.clients.claim();
    })()
  );
});

By combining explicit quota monitoring, isolated error boundaries, and deterministic pruning workflows, engineering teams can deliver resilient, production-grade offline experiences that scale across diverse network conditions and device constraints.

Security Note

Cached Response objects can include authorized content — a stale-while-revalidate cache may retain a response carrying one user’s data after an account switch. Scope cache names per user and purge them on logout. Never place bearer tokens or secrets in cached responses; for the token storage model, see Securing Auth Tokens in Browser Storage.

Frequently Asked Questions

Should I use cache.addAll() or individual cache.put() calls?

Use individual cache.put() calls wrapped in Promise.allSettled() for critical bundles. cache.addAll() is atomic: a single 404, CORS error, or quota breach (strictly enforced in Firefox) aborts the entire batch and can prevent the service worker from installing. Isolating each request lets you log the failure and still activate.

Why does my PWA lose its cached assets on iPhone after a week?

Safari’s Intelligent Tracking Prevention purges script-writable storage, including the Cache API, after roughly 7 days without a visit, and it applies LRU eviction to background tabs sooner. Back critical routes with an IndexedDB manifest so the service worker can detect the loss and re-precache on the next launch instead of failing offline.

How much can the Cache API store?

Quotas are dynamic and per-origin. Chromium may grant up to about 60% of free disk space, while Safari is far more conservative and aggressive about eviction. Call navigator.storage.estimate() to read current usage and quota, and prune by MIME-type priority before you hit the ceiling rather than waiting for a QuotaExceededError.

Why does my new deploy keep serving old CSS?

The old service worker keeps controlling open tabs until they all close, and if you reuse the same cache name the stale assets are never pruned. Version the cache name on every deploy so the new worker precaches into a fresh namespace, prune non-matching names in activate, and call clients.claim() — optionally with skipWaiting() and a client-side reload prompt — to cut over cleanly.

Can I cache JSON API responses with the Cache API?

You can, since JSON arrives as an HTTP Response, but it is often the wrong fit when you need to query, index, or partially update records. That decision is nuanced enough to have its own walkthrough — see IndexedDB vs Cache API for Offline JSON Payloads. Reserve the Cache API for whole-response, asset-style data.

Related