Choosing a Service Worker Caching Strategy for Offline-First HTML5 Apps
A decision guide for choosing and implementing service worker caching strategies in HTML5 apps. Covers cache-first, network-first, and stale-while-revalidate patterns with a production-ready hybrid implementation, validation steps, and browser-specific limitations.
20 Nov 2025, 09:25 UTC

The Problem: Offline Reliability Without Stale Data
You're building a progressive web app that must work offline. The core decision isn't whether to use a service worker—it's which caching strategy matches your content type and freshness requirements. A cache-first approach serves static assets instantly from the browser's CacheStorage, but risks showing outdated HTML or API responses. A network-first strategy guarantees freshness but fails completely when the network is unavailable. Most production apps need a hybrid: cache-first for versioned static assets, network-first for dynamic data, and stale-while-revalidate for content that tolerates brief staleness.
Decision Constraints
- Asset mutability: Immutable files (hashed filenames, versioned bundles) are safe for long-term caching. Mutable files (index.html, API responses) need validation or short TTLs.
- Freshness tolerance: User-generated content, pricing, and auth state require network-first. UI shell, fonts, and framework bundles tolerate cache-first.
- Storage budget: Browsers evict caches under storage pressure. Design eviction policies (max entries, max age) rather than relying on LRU alone.
- Browser support: Service workers require HTTPS and are unavailable in Safari < 11.1, old WebViews, and some enterprise browsers. Plan a graceful degradation path.
Strategy Comparison
| Strategy | Best For | Latency | Staleness Risk | Offline Behavior |
|---|---|---|---|---|
| Cache-first | Versioned JS/CSS, fonts, images | Lowest (cache hit) | High if not versioned | Works fully offline |
| Network-first | API calls, auth, user data | Network-dependent | None | Fails offline unless cached fallback exists |
| Stale-while-revalidate | Article content, product listings | Low (serves stale immediately) | Bounded by revalidation | Shows last cached version offline |
| Cache-only | Precached app shell | Lowest | High (never updates) | Works fully offline |
| Network-only | Non-GET mutations, real-time data | Network-dependent | None | Fails offline |
Trade-off Analysis
Cache-First: Speed at the Cost of Freshness
Cache-first checks CacheStorage before the network. On hit, it returns the cached response instantly—often under 10ms. On miss, it fetches, caches, and returns the network response. The danger: if you cache index.html without a version hash, users may load a stale shell that references outdated JS bundles, causing hydration mismatches or broken routes. Mitigate by:
- Hashing all static asset filenames (e.g.,
app.abc123.js) via your bundler. - Never caching unversioned HTML or API responses with cache-first.
- Using the
activateevent to delete old caches when a new service worker version takes control.
Network-First: Freshness at the Cost of Reliability
Network-first attempts the network, falls back to cache on failure. This guarantees users see the latest data when online, but any network error (timeout, 5xx, offline) triggers the fallback. For GET requests, pair with a short cache TTL (e.g., 5 minutes) so the fallback isn't ancient. For mutations (POST/PUT), never cache—queue them for background sync instead.
Stale-While-Revalidate: The Practical Middle Ground
This pattern returns the cached response immediately (if present) while simultaneously fetching a fresh copy to update the cache. Users see content instantly; the next visit gets the update. Ideal for semi-dynamic content: blog posts, product catalogs, dashboard widgets. The trade-off is complexity—you must manage two async operations and handle race conditions where the network response arrives before the cached response is read.
Concrete Implementation: Hybrid Strategy
The following service worker implements a production-ready hybrid: cache-first for versioned static assets, network-first with timeout for API calls, and stale-while-revalidate for HTML documents. Register it from your main bundle after the page loads.
service-worker.js
const CACHE_VERSION = 'v2024-01-15-01';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const MAX_DYNAMIC_ENTRIES = 50;
const NETWORK_TIMEOUT_MS = 3000;
// Assets to precache during install
const PRECACHE_URLS = [
'/',
'/offline.html',
'/manifest.json'
// Bundled assets (hashed filenames) added by build tool
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET and cross-origin requests
if (request.method !== 'GET' || url.origin !== location.origin) {
return;
}
// Strategy routing by path pattern
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
} else if (isApiCall(url.pathname)) {
event.respondWith(networkFirstWithTimeout(request, DYNAMIC_CACHE));
} else if (isHtmlDocument(request)) {
event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
} else {
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
}
});
function isStaticAsset(pathname) {
return /\.(js|css|woff2?|png|jpg|jpeg|svg|ico|webp|avif)$/i.test(pathname);
}
function isApiCall(pathname) {
return pathname.startsWith('/api/');
}
function isHtmlDocument(request) {
return request.headers.get('accept')?.includes('text/html');
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
return new Response('Offline', { status: 503 });
}
}
async function networkFirstWithTimeout(request, cacheName, timeout = NETWORK_TIMEOUT_MS) {
const cache = await caches.open(cacheName);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(request, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
await limitCacheSize(cache, MAX_DYNAMIC_ENTRIES);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
limitCacheSize(cache, MAX_DYNAMIC_ENTRIES);
cache.put(request, response.clone());
}
return response;
}).catch(() => cached);
return cached || fetchPromise;
}
async function limitCacheSize(cache, maxEntries) {
const keys = await cache.keys();
if (keys.length >= maxEntries) {
await cache.delete(keys[0]); // FIFO eviction
}
}
Registration Script (main.js)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((reg) => {
console.log('SW registered:', reg.scope);
// Optional: listen for updates
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available, prompt user to refresh
showUpdateBanner();
}
});
});
})
.catch((err) => console.error('SW registration failed:', err));
});
}
Validation Checklist
Verify the implementation in Chrome DevTools (or Firefox/Edge equivalents):
- Registration: Open Application → Service Workers. Confirm status shows "activated and running" with a green dot.
- Precache: In Application → Cache Storage →
static-{version}, verify allPRECACHE_URLSentries exist with 200 status. - Cache-first hit: Reload the page. In Network tab, filter by "Service Worker". Static assets should show "(from ServiceWorker)" with near-zero latency.
- Network-first fallback: Throttle to "Offline" in Network tab. Navigate to an API-dependent page. Confirm the cached JSON response appears (or your custom offline JSON).
- Stale-while-revalidate: Go online, modify a server-rendered HTML page, reload. First load shows old content (from cache), second load shows new content (cache updated in background).
- Cache eviction: Exceed
MAX_DYNAMIC_ENTRIESby visiting many unique API endpoints. Confirm oldest entries are removed via FIFO in Cache Storage viewer. - Update flow: Deploy a new
service-worker.jswith incrementedCACHE_VERSION. Reload—new worker installs, activates, and cleans old caches. The update banner should appear.
Limitations and Gotchas
- Opaque responses: Cross-origin resources (CDN fonts, third-party scripts) cached via
fetch()become opaque—you can't read their headers or body, and they count ~7MB each against quota. Host critical assets on your origin or use a reverse proxy. - Range requests: Video/audio streaming uses HTTP 206 partial responses. The above cache-first implementation doesn't handle range headers. For media, either exclude from caching or implement range-aware caching (complex, often not worth it).
- Storage quota: Browsers allocate ~10-50% of free disk space per origin. Monitor with
navigator.storage.estimate()and implement LRU or TTL eviction inlimitCacheSizefor production apps. - Safari quirks: Safari 14+ supports service workers but clears caches after 7 days of non-use. Don't assume long-term persistence for offline-first guarantees on iOS.
- Development caching: During development, unregister the service worker (Application → Service Workers → Unregister) or use Incognito. Otherwise you'll debug stale code.
Quick Verification Command
Run in the page console to confirm cache contents programmatically:
caches.keys().then(keys => keys.forEach(k =>
caches.open(k).then(c => c.keys().then(keys =>
console.log(k, keys.map(r => r.url))
))
));
Expected output: an array of cache names, each with its cached URLs. Verify your versioned static assets appear in the static cache and API responses in the dynamic cache.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.