Cache API for Static Assets: Production-Grade Offline Delivery
Architectural Positioning & Storage Boundaries
When architecting offline-first applications, selecting the right persistence layer is critical. While developers often default to key-value stores, Understanding Web Storage APIs reveals that synchronous APIs like 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 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.
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.
Cross-Browser Quirks & Quota Management
Before implementing, review Browser Storage Fundamentals & Quotas to understand how browsers partition storage origins and enforce eviction thresholds. 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);
}
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. 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.