The power of service workers comes from choosing the right caching strategy for each type of resource. Different content needs different strategies — static assets, API responses, and images each have their own optimal approach.
Strategy Overview
| Strategy | Network | Cache | Best For |
|---|---|---|---|
| Cache First | Backup | Primary | Static assets, fonts, icons |
| Network First | Primary | Backup | API data, dynamic HTML |
| Stale While Revalidate | Both | Both | Frequently updated content |
| Cache Only | No | Only | Offline-critical assets |
| Network Only | Only | No | Analytics, real-time data |
Cache First (Cache Falling Back to Network)
Check the cache first. If the resource is cached, return it immediately. Otherwise, fetch from the network and cache the response for next time.
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
}Use for: CSS, JavaScript, fonts, images — files that rarely change. When you deploy a new version, update the cache name to force a refresh.
Network First (Network Falling Back to Cache)
Try the network first. If it fails (user is offline), fall back to the cached version.
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
throw error;
}
}Use for: API endpoints, HTML pages — content where freshness matters most, but you still want offline access to the last known version.
Network First with Timeout
Avoid waiting too long for a slow network by adding a timeout:
async function networkFirstWithTimeout(request, cacheName, timeoutMs = 3000) {
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs)
),
]);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
throw error;
}
}Stale While Revalidate
Return the cached version immediately (fast), then fetch a fresh version in the background for next time.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Fetch fresh version in background
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
// Return cached version immediately, or wait for network
return cached || fetchPromise;
}Use for: User avatars, social feeds, blog posts — content that should load fast but also stay reasonably up-to-date.
Cache Only
Only serve from cache. Never hit the network. Use for resources cached during installation.
async function cacheOnly(request) {
const cached = await caches.match(request);
if (!cached) {
throw new Error("Not in cache");
}
return cached;
}Network Only
Always go to the network. Never cache. Use for real-time data or analytics.
async function networkOnly(request) {
return fetch(request);
}Implementing a Strategy Router
Apply different strategies based on the request type:
const STATIC_CACHE = "static-v1";
const DYNAMIC_CACHE = "dynamic-v1";
const API_CACHE = "api-v1";
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET") return;
// Static assets — cache first
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// API requests — network first
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(request, API_CACHE));
return;
}
// HTML pages — stale while revalidate
if (request.mode === "navigate") {
event.respondWith(
staleWhileRevalidate(request, DYNAMIC_CACHE).catch(() =>
caches.match("/offline.html")
)
);
return;
}
// Everything else — stale while revalidate
event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
});
function isStaticAsset(url) {
return /\.(css|js|woff2?|png|jpg|svg|ico)$/.test(url.pathname);
}Cache Size Management
Caches can grow large. Limit the number of entries:
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// Delete oldest entries (first in the list)
const toDelete = keys.slice(0, keys.length - maxEntries);
await Promise.all(toDelete.map((key) => cache.delete(key)));
}
}
// Run after caching a new response
async function cacheWithLimit(request, response, cacheName, maxEntries = 50) {
const cache = await caches.open(cacheName);
await cache.put(request, response);
await trimCache(cacheName, maxEntries);
}Cache Expiration
Add time-based expiration to cached responses:
async function cacheFirstWithExpiry(request, cacheName, maxAgeMs) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
const dateHeader = cached.headers.get("sw-cache-date");
if (dateHeader) {
const age = Date.now() - new Date(dateHeader).getTime();
if (age < maxAgeMs) {
return cached;
}
}
}
// Fetch fresh and store with timestamp
const response = await fetch(request);
if (response.ok) {
const headers = new Headers(response.headers);
headers.set("sw-cache-date", new Date().toISOString());
const timedResponse = new Response(await response.clone().blob(), {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(request, timedResponse);
}
return response;
}Precaching vs Runtime Caching
| Type | When | What |
|---|---|---|
| Precache | During install event | App shell, critical assets |
| Runtime | During fetch event | API responses, images, pages |
// Precaching (install event)
const PRECACHE_ASSETS = [
"/",
"/app.js",
"/styles.css",
"/offline.html",
"/icons/logo.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE_ASSETS))
);
});
// Runtime caching (fetch event)
self.addEventListener("fetch", (event) => {
// Strategies applied per-request as shown above
});Practical Exercise
Build a complete caching layer with multiple strategies:
// sw.js
const CACHES = {
shell: "shell-v2",
pages: "pages-v1",
images: "images-v1",
api: "api-v1",
};
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET") return;
if (url.origin !== location.origin) return;
// Images: cache first, limit to 100 entries
if (/\.(png|jpg|jpeg|webp|svg|gif)$/.test(url.pathname)) {
event.respondWith(cacheFirst(event.request, CACHES.images));
return;
}
// API: network first with 3s timeout
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirstWithTimeout(event.request, CACHES.api, 3000));
return;
}
// Pages: stale while revalidate
if (event.request.mode === "navigate") {
event.respondWith(
staleWhileRevalidate(event.request, CACHES.pages).catch(() =>
caches.match("/offline.html")
)
);
return;
}
// Static assets: cache first
event.respondWith(cacheFirst(event.request, CACHES.shell));
});Key Takeaways
- Cache First is fastest for static assets that rarely change.
- Network First ensures fresh data with offline fallback for APIs and dynamic content.
- Stale While Revalidate balances speed and freshness by serving cached data while updating in the background.
- Route different strategies based on request type (assets, API, pages).
- Manage cache size with entry limits and time-based expiration to prevent unbounded growth.