You might be wondering how much space your service worker gets to play with. Just how many pages and images can you keep caching?
There isn’t a fixed amount of space set aside for service worker caches. There are a number of browser technologies—like localStorage, IndexedDB, and the Cache API—that share the space available on a device. There’s often a remarkably large amount of space to share, sometimes gigabytes of it.
Still, storage isn’t limitless. When a device starts to get full, it will attempt to do some cleaning up. But even at this point, your service worker caches won’t be the first to go. The HTTP cache is the first place where the browser will do some spring cleaning. That’s one reason why a bespoke service worker cache is more reliable than the shared HTTP cache.
Even though the files in your caches are fairly safe, you still don’t want to hoard any more than you need to. If every website started storing megabytes and megabytes of files, it would turn into a tragedy of the commons.
You can be a good citizen of the web by only caching files that you’re pretty sure will get used. You’re already doing the right thing by deleting old caches during the activate event. It would be nice if you could also periodically clean up individual caches by putting a cap on the number of files they can store.
Perhaps you don’t need to store every single article someone has ever read on your site; the most recent twenty or thirty articles viewed might be enough. Likewise, you don’t need to hold on to every image forever; limiting your image cache to fifty or sixty images might be enough.
You need some code to trim down the number of items in a specified cache. This is the perfect job for a function—a reusable chunk of code that you can run more than once. Let’s call the function trimCache and have it accept two arguments—the name of the cache to trim, and the maximum number of items we want the cache to store:
function trimCache(cacheName, maxItems) {
// Trim the number of items in cacheName to maxItems
Open up the specified cache and get a list of all the items in it using the keys method:
caches.open(cacheName)
.then( cache => {
cache.keys()
.then( items => {
In this situation, you don’t care what the items are—you just want to know how many there are. You can find out by querying the length property of items. You can compare that number to the maxItems argument:
if (items.length > maxItems)
If there are too many items in the cache (i.e. more than maxItems), you can delete an item.
You don’t want to delete the freshest item in the cache. It makes more sense to delete the oldest item—the first one in the array. Whereas we humans like counting from one, computers like to start with zero. That’s why the first item in an array has an index of zero, rather than one:
cache.delete(items[0])
Then you can repeat the whole operation:
.then( function() {
trimCache(cacheName, maxItems)
});
That will repeat the function with the same parameters. It will continue to loop until the number of items in the cache has been reduced to the value of maxItems.
Putting that all together, your function looks like this:
function trimCache(cacheName, maxItems) {
caches.open(cacheName)
.then( cache => {
cache.keys()
.then( items => {
if (items.length > maxItems) {
cache.delete(items[0])
.then( function() {
trimCache(cacheName, maxItems)
}); // end delete then
} // end if
}); // end keys then
}); // end open
} // end function
Your function is ready and waiting to be called. But when should you call it?
You could call the function from the activate event handler—that’s where you’re cleaning up out-of-date caches. But that event is triggered when someone returns to your site. If someone spends a long time browsing your site—and doesn’t return—their caches could get quite full. It would be better if you could trigger the trimCache function every time someone visits a page of your site. The fetch event seems like the right time to do this, but things could get messy if you’re already using that event to add items to caches.
The ideal time to trigger the trimCache function is after a page has loaded. You can’t access that event directly in your service worker script, but you can send instructions from a web page to a service worker using a method called postMessage.
Most of the logic in your service worker script is attached to events: install, activate, and fetch. But there’s one other useful event. It’s called message:
addEventListener('message', messageEvent => {
// Do something with messageEvent
});
This event can be triggered from any page that’s currently being controlled by the service worker. You can find out if a page is being controlled by a service worker by checking for the existence of navigator.serviceWorker.controller. Currently, your HTML page has a bit of feature detection like this:
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/serviceworker.js');
}
</script>
You can add a further bit of feature detection before assuming that a service worker is up and running:
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/serviceworker.js');
if (navigator.serviceWorker.controller) {
// A service worker is up and running!
}
}
</script>
Now you can safely trigger a message event from inside that if statement. Use the postMessage method of navigator.serviceWorker.controller:
navigator.serviceWorker.controller.postMessage(...);
You can put anything you like inside the argument for postMessage. You could, for example, send a string of text like “clean up caches”:
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/serviceworker.js');
if (navigator.serviceWorker.controller) {
window.addEventListener('load', function () {
navigator.serviceWorker.controller.postMessage('clean up caches');
});
}
}
</script>
The message “clean up caches” is sent once the page has finished loading. That message is now accessible from your service worker script through the message event. It shows up as a property of the event called data:
addEventListener('message', messageEvent => {
console.log(messageEvent.data);
});
If you add that code to your service worker script, when your page loads you’ll see this message in the console of your browser’s developer tools:
clean up caches
Now, instead of logging the message to the console, use it to trigger the trimCache function you wrote:
addEventListener('message', messageEvent => {
if (messageEvent.data == 'clean up caches') {
trimCache(pagesCacheName, 20);
trimCache(imageCacheName, 50);
}
});
That will trim the cache of pages down to twenty items, and the cache of images down to fifty. I chose those numbers at random; use whatever amounts are right for your site. Whatever you choose, the important thing is that you’re practicing good cache hygiene. The Cache API gives you a lot of power. Now you’re wielding that power in a responsible way.
Your trimCache function is a perfect example of abstracting code into a reusable chunk. Whenever you find yourself writing the same kind of code more than once, it might be a good idea to turn that chunk of code into a function so that you can reuse it.
The fetch-handling code in your service worker script probably has some duplicated functionality scattered throughout. An example would be wherever your logic includes this flow:
You could wrap that logic up into a reusable function called stashInCache. The details will change each time you need to use this code—Which file to fetch? Which cache to put it in? Turn those details into arguments. Call them, say, request and cacheName:
function stashInCache(request, cacheName) {
// Fetch the file
fetch(request)
.then( responseFromFetch => {
// Open the cache
caches.open(cacheName)
.then( theCache => {
// Put the file into the cache
return theCache.put(request, responseFromFetch);
}); // end open then
}); // end fetch then
}
Here’s an example of where you might use this stashInCache function. In your logic for images, you have a step where you fetch and then cache a fresh version of the image:
Now you can replace those last two steps with a call to the stashInCache function:
// When the user requests an image
if (request.headers.get('Accept').includes('image')) {
fetchEvent.respondWith(
// Look for a cached version of the image
caches.match(request)
.then( responseFromCache => {
if (responseFromCache) {
// Fetch and cache a fresh version
fetchEvent.waitUntil(
stashInCache(request, imageCacheName)
); // end waitUntil
return responseFromCache;
} // end if
There’s also this logic for handling article pages:
Here’s the code for that, but this time it’s using the stashInCache function:
// When the requested page is an article
if (/\/articles\/(.+)/.test(request.url)) {
fetchEvent.respondWith(
// Look in the cache
caches.match(request)
.then( responseFromCache => {
if (responseFromCache) {
// Fetch and cache a fresh version
fetchEvent.waitUntil(
stashInCache(request, pagesCacheName)
); // end waitUntil
return responseFromCache;
} // end if
Now you’ve managed to avoid some code duplication. As an added bonus, you’ve also managed to reduce the amount of nesting in your fetch-handling code. I find that the more deeply nested my code gets, the harder it is to read.
Because promises are asynchronous, whenever you want to do something with the result of a promise, you have to do so inside a then clause. If the logic you’re trying to code is “Do this, and then do that, and then do something else”, you’ll find your code is nested three levels deep.
The stashInCache function is a typical example of this. There are three steps—“fetch the file, open the cache, and put the file into the cache”—but each step depends on the result of the step before. That’s why the structure of the code looks like an arrowhead—each step in the process depends on the step before.
function stashInCache(request, cacheName) {
// Fetch the file
fetch(request)
.then( responseFromFetch => {
// Open the cache
caches.open(cacheName)
.then( theCache => {
// Put the file into the cache
return theCache.put(request, responseFromFetch);
}); // end open then
}); // end fetch then
} // end function
If you tried to rewrite the function without the nested then clauses, your code wouldn’t work:
function stashInCache(request, cacheName) {
// Fetch the file
const responseFromFetch = fetch(request);
// Open the cache
const theCache = caches.open(cacheName);
// Put the file into the cache
return theCache.put(request, responseFromFetch);
}
The browser will execute that return statement before there’s a final value for responseFromFetch or theCache (because both fetch and caches.open are asynchronous). That’s a shame. The sequential code looks so much nicer than the nested code.
Now there’s a way to write code that looks sequential, but actually waits for each promise to resolve!
If you use the magic word async when you declare your function, then you can use the word await within that function.
async function stashInCache(request, cacheName) {
// Fetch the file
const responseFromFetch = await fetch(request);
// Open the cache
const theCache = await caches.open(cacheName);
// Put the file into the cache
return await theCache.put(request, responseFromFetch);
}
That function looks like a series of three sequential statements, so it’s nice and easy to read. But because of the await keyword, you can go ahead and reference responseFromFetch and theCache in your closing statement. The async function is sending back a promise. That promise won’t be resolved until responseFromFetch and theCache have values (when both fetch and caches.open have been resolved).
The upshot of all this is that async functions allow you to rewrite your code to look neater. Async functions don’t provide any new functionality; they’re just another way to write code that deals with promises. Your service worker script is filled with code that handles promises, so if you wanted to, you could rewrite your code completely.
Here’s an example of a straightforward promise, written the old-fashioned way. It’s responding to a fetch event by retrieving the request from the network:
fetchEvent.respondWith(
fetch(request)
); // end respondWith
Here’s the same functionality rewritten as an anonymous async function:
fetchEvent.respondWith(
async function() {
return await fetch(request);
}() // end async function
); // end respondWith
The extra pair of parentheses at the close of the async function are there so that the function is executed straightaway. Those parentheses are necessary for the code to work, but I wish they weren’t—the end of the code looks like somebody tried to encode some very complex emotions into a text message.
So far, the async function isn’t making the code any clearer. Here’s a slightly more complex logic example:
Here’s the code for that, using fetch and catch:
fetchEvent.respondWith(
// Try fetching the file from the network
fetch(request)
.catch( error => {
// Otherwise look for a cached version of the file
return caches.match(request)
}) // end fetch catch
); // end respondWith
To rewrite that functionality using async and await, you’ll need to rephrase your logic using try and catch:
fetchEvent.respondWith(
async function() {
try {
// Try fetching the file from the network
return await fetch(request);
} // end try
catch (error) {
// Otherwise look for a cached version of the file
return await caches.match(request);
} // end catch
}() // end async function
); // end respondWith
Everything inside the curly braces after try is your first choice. Everything inside the curly braces after catch will only be executed if your first choice doesn’t work out (notice that there isn’t a dot before the word catch this time).
Apart from the extra parentheses at the end, that code reads quite nicely. I also like the politeness of using a try statement, as though you’re gently saying to the service worker: “Hey there, buddy, give it your best shot. And if it doesn’t work out, well, I’ll be there to catch you. Literally…with a catch statement.”
Finally, here’s a three-step process:
That’s a typical service worker strategy for HTML pages. Here’s the code:
fetchEvent.respondWith(
// Try fetching the file from the network
fetch(request)
.catch( error => {
// Otherwise look for a cached version of the file
return caches.match(request)
.then( responseFromCache => {
if (responseFromCache) {
return responseFromCache;
} // end if
// Otherwise show the fallback page
return caches.match('/offline.html');
}); // end match then and return
}) // end fetch catch
); // end respondWith
Here’s the same functionality, rewritten as an async function:
fetchEvent.respondWith(
async function() {
try {
// Try fetching the file from the network
return await fetch(request);
} // end try
catch (error) {
// Otherwise look for a cached version of the file
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
} // end if
// Otherwise show the fallback page
return caches.match('/offline.html');
} // end catch
}() // end async function
); // end respondWith
If you find the async functions easier to read, then consider updating your code. But if you’re happy with the old-school style of promises, you can leave your code be. It’s your decision—use whatever coding style makes the most sense to you.
Remember that the style of your code doesn’t change its functionality. When it comes to the functionality of your service worker, you’ve got all the building blocks you need to program just about anything you can imagine.
It’s time to leave the service worker script and look at a different file that you can make improvements to—your offline page.
The Offline User Experience