Caching everything is not the right answer. A CSS file that hasn't changed in months is fine to serve from cache indefinitely. An APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. response that contains live stock prices should never come from cache. The strategies below are named patterns, each one is a different answer to the question: "should I hit the cache, the network, or both?"
Why different resources need different strategies
Your app is made of very different kinds of resources, and they have conflicting priorities:
| Resource type | Priority | Best strategy |
|---|---|---|
| HTML shell, CSS, JS bundles | Speed | Cache First |
| API responses, user data | Freshness | Network First |
| Images, fonts | Speed with occasional updates | Stale While Revalidate |
| Real-time data (prices, scores) | Accuracy | Network Only |
Versioned assets (app.abc123.js) | Speed, never stale | Cache Only |
The five strategies
Cache only
Never contact the network. Return whatever is in cache, or fail. This only makes sense for assets that are pre-cached during install and never change, like content-hashed build artifacts.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
// returns undefined if not found - browser shows network error
);
});Network only
Always fetch from the network, never consult the cache. Use this for real-time data or analytics pings where a cached response would be wrong.
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});Cache first
Check cache first; if not there, fetch from network and add to cache. This is the default strategy for most static assets.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(networkResponse => {
// Cache the new response for next time
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
})
);
});my-app-v2) and clear old caches in activate.Network first
Try the network first; fall back to cache if the request fails. The user gets fresh data when online and a cached version when offline.
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(networkResponse => {
// Keep cache up to date while online
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
})
.catch(() => caches.match(event.request))
);
});Stale while revalidate
Return whatever is in cache immediately (so the UI loads fast), then fetch from the network in the background and update the cache for the next request. The user sees potentially stale content on first load, but fresh content on the next visit.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
// Kick off a background refresh regardless
const networkFetch = fetch(event.request).then(networkResponse => {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
// Return cache immediately, or wait for network if no cache
return cached || networkFetch;
})
);
});This is the best general-purpose strategy for content pages, blog posts, and product listings, things that change occasionally but don't need to be real-time.
Building an offline fallback page
No matter which strategy you use, you should always have an offline.html to show when a navigation request fails and nothing is cached. Pre-cache it during install:
const STATIC_ASSETS = ['/', '/offline.html', '/styles.css', '/app.js'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(event.request) || caches.match('/offline.html')
)
);
}
});Background syncWhat is background sync?A Service Worker API that queues network requests made while offline and replays them automatically when connectivity is restored.
Background sync lets you queue a failed write operation and replay it automatically when connectivity is restored, even if the user has closed your page.
// In your app: queue the action if offline
async function saveNote(data) {
try {
await fetch('/api/notes', { method: 'POST', body: JSON.stringify(data) });
} catch {
// Store locally and register a sync tag
localStorage.setItem('pending-note', JSON.stringify(data));
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-notes');
}
}
// In sw.js: handle the sync event when connectivity returns
self.addEventListener('sync', event => {
if (event.tag === 'sync-notes') {
event.waitUntil(flushPendingNotes());
}
});
async function flushPendingNotes() {
const data = localStorage.getItem('pending-note');
if (!data) return;
await fetch('/api/notes', { method: 'POST', body: data });
localStorage.removeItem('pending-note');
}online event as a secondary mechanism.Testing offline behavior
The fastest way to test is in Chrome DevTools:
- Network tab → select "Offline" from the throttling dropdown
- Application → Service Workers → check "Offline" to simulate for the SW scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it.
- Application → Cache Storage → inspect what's actually cached
You can also listen for connectivity changes in your app code:
window.addEventListener('online', () => {
console.log('Back online - syncing...');
document.getElementById('status').textContent = 'Online';
});
window.addEventListener('offline', () => {
console.log('Offline - using cached data');
document.getElementById('status').textContent = 'Offline';
});
// Check current state on load
if (!navigator.onLine) {
console.log('Starting in offline mode');
}Quick reference
| Strategy | Cache hit | Network fail | Best for |
|---|---|---|---|
| Cache only | Return cache | Return nothing | Versioned static assets |
| Network only | Ignore cache | Show error | Real-time data |
| Cache first | Return cache | Try network | Images, fonts, CSS/JS |
| Network first | Fall back to cache | Return cache | API data, dynamic content |
| Stale while revalidate | Return cache + update | Return cache | Content pages, listings |
// Service Worker with differentiated strategies
const CACHE_NAME = 'my-app-v2';
const STATIC_CACHE = 'static-v2';
const DYNAMIC_CACHE = 'dynamic-v2';
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/app.js',
'/icons/icon-192.png'
];
// Installation
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activation
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => !name.startsWith('static') && !name.startsWith('dynamic'))
.map(name => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch with intelligent routing
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// HTML pages, Network First with offline fallback
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.catch(() => caches.match(request) || caches.match('/offline.html'))
);
return;
}
// API calls, Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request))
);
return;
}
// Static assets (JS, CSS), Cache First
if (url.pathname.match(/\.(js|css)$/)) {
event.respondWith(
caches.match(request).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
const clone = response.clone();
caches.open(STATIC_CACHE).then(cache => cache.put(request, clone));
return response;
});
})
);
return;
}
// Images, Stale While Revalidate
if (url.pathname.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) {
event.respondWith(
caches.match(request).then(cached => {
const fetchPromise = fetch(request).then(networkResponse => {
caches.open(DYNAMIC_CACHE).then(cache => {
cache.put(request, networkResponse.clone());
});
return networkResponse;
}).catch(() => cached);
return cached || fetchPromise;
})
);
return;
}
// Default: Network First
event.respondWith(
fetch(request).catch(() => caches.match(request))
);
});
// Background sync
self.addEventListener('sync', event => {
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync());
}
});
async function doBackgroundSync() {
// Sync pending data
const db = await openDB('sync-store', 1);
const pending = await db.getAll('pending');
for (const item of pending) {
try {
await fetch(item.url, {
method: item.method,
body: item.body
});
await db.delete('pending', item.id);
} catch (error) {
console.error('Sync failed for', item.id);
}
}
}