s from caching
});
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
dispatchEvent(new CustomEvent('app-update-available'));
}
});
});
} catch (error) {
console.error('Network proxy registration failed:', error);
}
}
### 2. Cache Registry & Versioning
Hardcoded cache names lead to orphaned storage. A centralized registry with explicit versioning ensures clean migrations.
```typescript
// src/network/cache-registry.ts
export const CACHE_VERSION = 'v4';
export const SHELL_CACHE = `app-shell-${CACHE_VERSION}`;
export const DYNAMIC_CACHE = `dynamic-assets-${CACHE_VERSION}`;
export const MAX_DYNAMIC_ENTRIES = 50;
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export async function pruneDynamicCache(): Promise<void> {
const cache = await caches.open(DYNAMIC_CACHE);
const keys = await cache.keys();
if (keys.length > MAX_DYNAMIC_ENTRIES) {
const oldest = keys.slice(0, keys.length - MAX_DYNAMIC_ENTRIES);
await Promise.all(oldest.map(key => cache.delete(key)));
}
}
3. Fetch Interception & Routing
Instead of monolithic if/else chains, route requests through a matcher pattern. This separates static assets, API calls, and fallback logic.
// src/network/network-proxy.ts
import { SHELL_CACHE, DYNAMIC_CACHE, pruneDynamicCache } from './cache-registry';
const STATIC_ASSETS = /\.(js|css|png|jpg|svg|woff2)$/;
const API_ENDPOINTS = /^\/api\//;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles/main.css',
'/scripts/runtime.js',
'/assets/fallback.html'
]);
})
);
});
self.addEventListener('activate', (event) => {
const validCaches = [SHELL_CACHE, DYNAMIC_CACHE];
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => !validCaches.includes(key)).map((key) => caches.delete(key))
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') return;
event.respondWith(handleRequest(request));
});
async function handleRequest(request: Request): Promise<Response> {
if (STATIC_ASSETS.test(request.url)) {
return cacheFirstStrategy(request);
}
if (API_ENDPOINTS.test(request.url)) {
return networkFirstStrategy(request);
}
return staleWhileRevalidateStrategy(request);
}
4. Strategy Implementations
Each strategy serves a distinct architectural purpose. Cache-first guarantees instant shell delivery. Network-first ensures fresh data. Stale-while-revalidate balances speed with eventual consistency.
async function cacheFirstStrategy(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) return cached;
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch {
return caches.match('/assets/fallback.html') as Promise<Response>;
}
}
async function networkFirstStrategy(request: Request): Promise<Response> {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
await pruneDynamicCache();
}
return networkResponse;
} catch {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503 });
}
}
async function staleWhileRevalidateStrategy(request: Request): Promise<Response> {
const cached = await caches.match(request);
const fetchPromise = fetch(request).then(async (networkResponse) => {
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
await pruneDynamicCache();
}
return networkResponse;
}).catch(() => cached);
return cached || fetchPromise;
}
Architecture Rationale
- Separation of Concerns: Routing logic is decoupled from strategy execution, making the worker testable and extensible.
- Explicit Versioning: Cache names include version tags. Activation cleans orphaned storage, preventing quota exhaustion.
self.clients.claim(): Forces immediate control over open tabs, eliminating the "refresh to update" friction while maintaining safety.
- Graceful Degradation: Every strategy includes a fallback chain. Network failures never result in undefined responses.
Pitfall Guide
1. Blocking the Install Event
Explanation: Performing heavy synchronous operations or caching non-critical assets inside the install handler delays activation. The worker remains in a waiting state, and the old worker continues serving requests.
Fix: Pre-cache only the application shell and critical runtime assets. Defer heavy media or secondary routes to a background sync or lazy-load them on first navigation.
2. Unbounded Cache Growth
Explanation: Dynamic caching without eviction policies quickly exhausts browser storage quotas. Once the quota is reached, subsequent cache writes fail silently, breaking offline functionality.
Fix: Implement LRU (Least Recently Used) eviction. Track entry counts, enforce maximum limits, and purge stale entries during activation or periodic sync events.
3. Ignoring Scope & Path Constraints
Explanation: Service Workers only intercept requests within their directory scope. Placing network-proxy.js in /assets/ means it cannot control root-level navigation or API calls outside that path.
Fix: Always register at the root (/) or explicitly define the scope option during registration. Verify scope boundaries in Chrome DevTools under Application > Service Workers.
4. Silent Fetch Failures
Explanation: Catch blocks that return undefined or swallow errors cause the browser to display generic network error pages. The user experience degrades without logging or fallback content.
Fix: Always return a valid Response object. Implement a dedicated fallback route (e.g., /assets/fallback.html) and ensure every strategy chain terminates with a concrete response.
5. Activation Race Conditions
Explanation: New workers wait for existing tabs to close before activating. Users navigating between pages may never trigger activation, leaving them on outdated code.
Fix: Use self.clients.claim() during activation to take immediate control. Pair this with a UI prompt that requests a page refresh when app-update-available fires, ensuring deterministic updates.
6. Push Payload Assumptions
Explanation: Assuming event.data always contains valid JSON causes runtime crashes when push services send empty payloads or malformed structures.
Fix: Validate payload structure before parsing. Implement fallback notification content and log parsing failures to your error tracking system. Never assume network reliability for push delivery.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static Assets (JS/CSS/Images) | Cache First | Guarantees instant delivery; assets rarely change between deployments | Low bandwidth, high perceived speed |
| API Data / User Profiles | Network First | Ensures data freshness; falls back to cache only on failure | Moderate bandwidth, high data accuracy |
| News Feeds / Social Streams | Stale-While-Revalidate | Balances instant UI with eventual consistency; updates in background | Optimized bandwidth, smooth UX |
| Authentication / Payments | Network Only | Security requirements prevent caching sensitive or mutable state | Highest bandwidth, mandatory compliance |
| Large Media / Videos | Cache First + Range Requests | Supports resumable downloads and reduces repeated egress | High initial storage, long-term bandwidth savings |
Configuration Template
// network-proxy.ts (Production-Ready Skeleton)
const CACHE_VERSION = 'prod-v1';
const SHELL = `shell-${CACHE_VERSION}`;
const DYNAMIC = `dyn-${CACHE_VERSION}`;
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(SHELL).then(c => c.addAll(['/index.html', '/runtime.js', '/styles.css']))
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => ![SHELL, DYNAMIC].includes(k)).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request).then(cached => {
const network = fetch(e.request).then(res => {
if (res.ok) caches.open(DYNAMIC).then(c => c.put(e.request, res.clone()));
return res;
}).catch(() => cached);
return cached || network;
})
);
});
self.addEventListener('sync', (e) => {
if (e.tag === 'pending-uploads') {
e.waitUntil(syncPendingRequests());
}
});
self.addEventListener('push', (e) => {
const data = e.data?.json() ?? { title: 'Update', body: 'New content available' };
e.waitUntil(
self.registration.showNotification(data.title, { body: data.body, icon: '/icon.png' })
);
});
Quick Start Guide
- Create the worker file: Place
network-proxy.ts at your project root. Compile it to network-proxy.js using your build pipeline.
- Register in the entry point: Import and call
initializeNetworkProxy() in your main application bootstrap. Ensure it runs after DOMContentLoaded.
- Define cache routes: Map your asset patterns to the appropriate strategy (Cache First for static, Network First for APIs, Stale-While-Revalidate for content).
- Test offline behavior: Open Chrome DevTools > Application > Service Workers. Check "Offline" and verify that cached routes return valid responses while uncached routes fail gracefully.
- Deploy over HTTPS: Push to a secure origin. Verify activation in the DevTools panel and confirm that
self.clients.claim() executes without errors. Run a Lighthouse PWA audit to validate installability and performance thresholds.