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 the main thread
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.warn('[Cache API] Partial precache failure:', failures.map(f => f.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 >14 days, 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. - Chromium (Blink): Requires Background Sync API integration to defer cache population on flaky mobile networks. Without
navigator.serviceWorker.readyandsync.register(), precaching may fail silently during offline-to-online transitions.
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, quota } = 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 CDN-served assets when local storage limits are reached. A hard 3-second activation timeout must trigger a direct CDN fetch to prevent main-thread hangs during cold starts. When quota limits are breached, implement selective asset pruning by MIME type priority: application/javascript > text/css > image/* > font/*.
// sw.ts - Fetch event with exponential backoff & CDN 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 3s timeout & exponential backoff
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 (err) {
clearTimeout(timeoutId);
// Graceful degradation: fallback to CDN origin
const cdnUrl = `https://cdn.example.com${new URL(event.request.url).pathname}`;
return fetch(cdnUrl);
}
return new Response('Asset unavailable offline', { status: 503 });
})()
);
});
Finally, maintaining cache hygiene requires automated cleanup routines; refer to Clearing browser cache programmatically without losing app state for safe deletion patterns, and implement Handling service worker cache versioning at scale to ensure seamless updates without breaking offline availability. 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.