uching app-shell-v1. Version suffixes prevent stale service workers from serving outdated assets after deployment.
type CacheNamespace = 'app-shell' | 'api-data' | 'media-assets';
const CACHE_VERSION = 'v3';
const CACHE_REGISTRY: Record<CacheNamespace, string> = {
'app-shell': `shell-${CACHE_VERSION}`,
'api-data': `api-${CACHE_VERSION}`,
'media-assets': `media-${CACHE_VERSION}`
};
Step 2: Implement Strategy Handlers
Each strategy encapsulates its own fetch logic, timeout handling, and cache interaction. All operations are explicitly awaited to prevent silent Promise rejections.
async function cacheFirst(request: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) return cachedResponse;
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch {
return new Response('Offline: Resource not cached', { status: 503 });
}
}
async function networkFirstWithTimeout(
request: Request,
cacheName: string,
timeoutMs: number = 3000
): Promise<Response> {
const cache = await caches.open(cacheName);
const networkPromise = fetch(request);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Network timeout')), timeoutMs)
);
try {
const response = await Promise.race([networkPromise, timeoutPromise]);
await cache.put(request, (response as Response).clone());
return response as Response;
} catch {
const cached = await cache.match(request);
return cached || new Response('Offline: No cached version available', { status: 503 });
}
}
async function staleWhileRevalidate(request: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then(async (networkResponse) => {
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
}
}).catch(() => {});
fetchPromise.catch(() => {});
return cachedResponse || fetchPromise.then(() => cache.match(request)).then(r => r || new Response('Not found', { status: 404 }));
}
Step 3: Build a Pattern-Based Router
Production applications route requests based on URL structure, file extensions, or API prefixes. A declarative matcher keeps strategy assignment maintainable.
interface RouteRule {
pattern: RegExp;
strategy: (req: Request) => Promise<Response>;
cacheNamespace: CacheNamespace;
}
const ROUTE_RULES: RouteRule[] = [
{
pattern: /\.(js|css|woff2|png|jpg)$/,
strategy: (req) => cacheFirst(req, CACHE_REGISTRY['app-shell']),
cacheNamespace: 'app-shell'
},
{
pattern: /^\/api\/v1\//,
strategy: (req) => networkFirstWithTimeout(req, CACHE_REGISTRY['api-data'], 4000),
cacheNamespace: 'api-data'
},
{
pattern: /^\/settings|\/profile/,
strategy: (req) => staleWhileRevalidate(req, CACHE_REGISTRY['api-data']),
cacheNamespace: 'api-data'
}
];
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.method !== 'GET') return;
const matchingRule = ROUTE_RULES.find(rule => rule.pattern.test(event.request.url));
if (!matchingRule) return;
event.respondWith(matchingRule.strategy(event.request));
});
Architecture Rationale
- Separation of Concerns: Strategy functions are pure and testable. They do not depend on service worker globals, enabling unit testing in Node.js environments.
- Explicit Async Handling: Every
caches.open(), cache.match(), and cache.put() is awaited. This prevents race conditions where background updates overwrite fresh responses or leave dangling Promises.
- Timeout Enforcement: Network-first strategies without timeouts block the main thread on poor connections. The
Promise.race pattern guarantees a fallback path within a predictable window.
- Cache Cloning:
response.clone() is mandatory before caching because response bodies are streams that can only be consumed once. Forgetting this causes TypeError: Failed to execute 'clone' on 'Response': body stream is locked.
- Pattern Matching Over Hardcoded Paths: Regular expressions and prefix checks scale better than explicit URL lists. They accommodate dynamic routing, query parameters, and CDN path variations without constant service worker updates.
Pitfall Guide
1. Silent Promise Failures
Explanation: Cache Storage methods return Promises. Omitting await or .catch() causes operations to fail silently. The service worker continues execution, leaving caches in inconsistent states.
Fix: Wrap all cache interactions in try/catch blocks or use explicit .catch() handlers. Log failures to a monitoring endpoint or console.debug during development.
2. The Mutable URL Trap
Explanation: Applying Cache First to non-versioned URLs (e.g., /styles/main.css) locks users into stale assets until the cache is manually cleared or the service worker updates.
Fix: Reserve Cache First exclusively for content-hashed assets or immutable media. Use Stale While Revalidate or Network First for mutable endpoints.
3. POST Request Misplacement
Explanation: Cache Storage only supports GET. Attempting to cache POST, PUT, or DELETE responses throws errors or fails silently.
Fix: Route non-GET requests to Network Only. For offline write support, pair IndexedDB with the Background Sync API to queue mutations and replay them when connectivity returns.
4. Unbounded Cache Growth
Explanation: Caches accumulate indefinitely. Browsers enforce storage quotas and evict data using LRU policies, which can unexpectedly remove critical assets.
Fix: Implement cache size limits. Periodically delete entries older than a threshold using cache.keys() and cache.delete(). Request persistent storage via navigator.storage.persist() for critical offline data.
5. Timeout Misconfiguration
Explanation: Setting network timeouts too short (e.g., 500ms) forces fallbacks on marginally slow connections. Setting them too long (e.g., 10s) negates the offline fallback benefit.
Fix: Base timeouts on real-world network telemetry. 2000β4000ms is standard for mobile networks. Adjust dynamically based on navigator.connection.effectiveType when available.
6. Cache Versioning Neglect
Explanation: Deploying a new service worker without updating cache names causes the old worker to serve outdated assets until the new worker activates and clears them.
Fix: Tie cache names to build hashes or semantic versions. In the activate event, iterate through caches.keys() and delete any cache not matching the current version registry.
7. Ignoring Response Status Codes
Explanation: Caching 404 or 500 responses propagates errors to offline users. The cache stores the failure state instead of the resource.
Fix: Only cache responses where response.ok === true. For error pages, cache a dedicated offline fallback template separately and serve it explicitly when network requests fail.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Versioned JS/CSS bundles | Cache First | URLs encode content hashes; zero staleness risk | Minimal storage, maximum speed |
| Real-time API endpoints | Network First + Timeout | Freshness required; timeout prevents UI blocking | Moderate bandwidth, predictable latency |
| User preferences/settings | Stale While Revalidate | Instant load acceptable; background refresh keeps data current | Low bandwidth, high perceived performance |
| Payment/checkout flows | Network Only | Side effects must not be cached or replayed incorrectly | Zero cache overhead, strict reliability |
| Strict offline documentation | Cache Only | Guarantees no network attempts; fully precached | High initial install size, zero runtime fetches |
Configuration Template
// sw.ts
type CacheBucket = 'shell' | 'api' | 'media';
const VERSION = '2024Q3';
const BUCKETS: Record<CacheBucket, string> = {
shell: `static-${VERSION}`,
api: `data-${VERSION}`,
media: `assets-${VERSION}`
};
async function openCache(bucket: CacheBucket): Promise<Cache> {
return caches.open(BUCKETS[bucket]);
}
async function cacheFirst(req: Request, bucket: CacheBucket): Promise<Response> {
const cache = await openCache(bucket);
const hit = await cache.match(req);
if (hit) return hit;
const net = await fetch(req);
if (net.ok) await cache.put(req, net.clone());
return net;
}
async function networkFirst(req: Request, bucket: CacheBucket, ms = 3000): Promise<Response> {
const cache = await openCache(bucket);
try {
const res = await Promise.race([
fetch(req),
new Promise<never>((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))
]);
await cache.put(req, (res as Response).clone());
return res as Response;
} catch {
const cached = await cache.match(req);
return cached || new Response('Unavailable offline', { status: 503 });
}
}
async function staleWhileRevalidate(req: Request, bucket: CacheBucket): Promise<Response> {
const cache = await openCache(bucket);
const cached = await cache.match(req);
fetch(req).then(async (res) => {
if (res.ok) await cache.put(req, res.clone());
}).catch(() => {});
return cached || new Response('Not cached', { status: 404 });
}
const ROUTES = [
{ match: /\.(js|css|woff2|svg)$/, handler: (r: Request) => cacheFirst(r, 'shell') },
{ match: /^\/api\//, handler: (r: Request) => networkFirst(r, 'api', 4000) },
{ match: /\/(settings|profile|config)/, handler: (r: Request) => staleWhileRevalidate(r, 'api') }
];
self.addEventListener('fetch', (evt: FetchEvent) => {
if (evt.request.method !== 'GET') return;
const rule = ROUTES.find(r => r.match.test(evt.request.url));
if (rule) evt.respondWith(rule.handler(evt.request));
});
self.addEventListener('activate', async (evt: ExtendableEvent) => {
evt.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => !Object.values(BUCKETS).includes(k)).map(k => caches.delete(k)))
)
);
});
Quick Start Guide
- Register the Service Worker: Add
navigator.serviceWorker.register('/sw.ts') to your application entry point. Ensure it's served over HTTPS or localhost.
- Define Cache Buckets: Map your content types to versioned cache names. Align versions with your CI/CD pipeline or build hashes.
- Attach Route Matchers: Use regular expressions or path prefixes to map incoming requests to strategy handlers. Keep the list ordered from most specific to most general.
- Validate Offline Behavior: Open DevTools β Application β Service Workers. Check "Offline" and "Bypass for network". Verify that cached assets load instantly, API routes timeout gracefully, and non-GET requests bypass the cache.
- Deploy and Monitor: Ship the service worker. Track cache hit ratios, timeout frequencies, and storage usage via
navigator.storage.estimate(). Adjust timeouts and cache boundaries based on real user network conditions.