The Service Worker Cache API is surprisingly bad at actually caching things without constant, manual intervention.
Let’s see it in action. Imagine a simple PWA that needs to serve an index.html and its associated app.js offline.
// sw.js
const CACHE_NAME = 'my-app-cache-v1';
const urlsToCache = [
'/',
'/app.js',
'/styles.css'
];
self.addEventListener('install', event => {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
// Not in cache - fetch from network and cache it
return fetch(event.request).then(
response => {
// Check if we received a valid response
if (!response || response.status !== 200 || response.type === 'error') {
return response;
}
// Important: clone the response. A response is a stream
// and is being consumed by the first return statement.
// Secondly, add the response to the cache.
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
}
);
})
);
});
When a user visits your PWA for the first time, the install event fires. This event opens a cache named my-app-cache-v1 and adds all the urlsToCache to it. Subsequent fetch events (when the browser requests a resource) first check if the resource is already in the cache. If it is, the cached version is served. If not, it fetches the resource from the network, caches it, and then serves it. This is the basic "cache-first" strategy.
The problem is what happens when you update your app. If you change app.js and increment CACHE_NAME to my-app-cache-v2, the install event will run again, creating a new cache. The old cache (my-app-cache-v1) still exists, holding the old app.js. The fetch handler, however, will only look in the active cache, which is now my-app-cache-v2. So, the old version of app.js remains in the old cache and is never served again, but your new app.js is in the new cache. This is good.
But what if you don’t change CACHE_NAME? If you update app.js on the server but keep CACHE_NAME as my-app-cache-v1, the install event won’t run again. The fetch event will still try to match app.js in my-app-cache-v1. It will find a response, and serve the stale version from the cache, completely ignoring the updated app.js on the network. This is the core problem: the Service Worker, by default, doesn’t know your assets have changed unless you tell it to create a new cache.
The real magic happens when you implement a strategy to update the cache. A common pattern is "stale-while-revalidate."
// sw.js (stale-while-revalidate example)
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
// Return the cached response if it's available
const networkFetch = fetch(event.request).then(
newResponse => {
// Cache the new response for the future
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, newResponse.clone());
});
// Return the network response
return newResponse;
}
);
// If response is available, return it, otherwise wait for network
return response || networkFetch;
})
);
});
In this "stale-while-revalidate" approach, caches.match(event.request) is called first. If a response is found in the cache, it’s immediately returned to the user. Concurrently, fetch(event.request) is initiated to get the latest version from the network. Once that network request completes, the new response is cloned and placed into the cache, overwriting the stale entry. The user gets the fast, cached response instantly, and the cache is updated in the background for the next request. This provides the perception of offline-first speed with up-to-date content.
The most surprising part of making this work reliably is understanding that caches.open() and cache.addAll() only run on install. If you want to update your existing cache entries without changing the CACHE_NAME, you need to manually trigger the put operation within your fetch handler for every request that might have changed. This means your fetch handler is not just a simple lookup; it’s the central point of truth for cache invalidation and updating.
The next thing you’ll grapple with is managing multiple cache versions during an update cycle, often involving a "clean-up" phase to remove old, unused caches.