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. Unlike key-value stores, it 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, and cache hygiene — that separate a demo from a resilient offline experience.
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.
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.
Cross-Browser Quirks & Quota Management
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:
- 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.
- Firefox (Gecko): Enforces strict atomicity on
cache.addAll(). A single404, CORS violation, or quota breach aborts the entire batch operation. Always validate asset availability and use individualcache.put()calls for resilience. - 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:
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 caching based on remaining quota
async function safeCachePut(
cache: Cache,
url: string,
response: Response
): Promise<void> {
const { available } = await checkStorageQuota();
const estimatedSize =
parseInt(response.headers.get('Content-Length') || '0', 10) || 50000;
if (available < estimatedSize * 1.5) {
throw new Error(
`[Quota] Insufficient space for ${url}. Triggering prune routine.`
);
}
await cache.put(url, response);
}
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.
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.
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
- When to Use the Cache API over IndexedDB — a decision guide for splitting assets from state.
- IndexedDB vs Cache API for Offline JSON Payloads — choosing a store for structured offline data.
- Service Worker Caching Strategies — cache-first, network-first, and stale-while-revalidate patterns.
- Storage Quotas & Eviction Policies — how origin quota and eviction are enforced.
- Browser Storage Fundamentals & Quotas — the parent guide to every storage primitive on the platform.