Cache-limiting in Service Workers …again

Okay, so remember when I was talking about cache-limiting in Service Workers?

It wasn’t quite working:

The cache-limited seems to be working for pages. But for some reason the images cache has blown past its allotted maximum of 20 (you can see the items in the caches under the “Resources” tab in Chrome under “Cache Storage”).

This is almost certainly because I’m doing something wrong or have completely misunderstood how the caching works.

Sure enough, I was doing something wrong. Thanks to Brandon Rozek and Jonathon Lopes for talking me through the problem.

In a nutshell, I’m mixing up synchronous instructions (like “delete the first item from a cache”) with asynchronous events (pretty much anything to do with fetching and caching with Service Workers).

Instead of trying to clean up a cache at the same time as I’m adding a new item to it, it’s better for me to have clean-up function to run at a different time. So I’ve written that function:

var trimCache = function (cacheName, maxItems) {
    caches.open(cacheName)
        .then(function (cache) {
            cache.keys()
                .then(function (keys) {
                    if (keys.length > maxItems) {
                        cache.delete(keys[0])
                            .then(trimCache(cacheName, maxItems));
                    }
                });
        });
};

But now the question is …when should I run this function? What’s a good event to trigger a clean-up? I don’t think the activate event is going to work. I probably want something like background sync but I don’t think that’s quite ready for primetime yet.

In the meantime, if you can think of a good way of doing a periodic clean-up like this, please let me know.

Anyone? Anyone? Bueller?

In other Service Worker news, I’ve added a basic Service Worker to The Session. It caches static caches—CSS and JavaScript—and keeps another cache of site section index pages topped up. If the network connection drops (or the server goes down), there’s an offline page that gives a few basic options. Nothing too advanced, but better than nothing.

Update: Brandon has been tackling this problem and it looks like he’s found the solution: use the page load event to fire a postMessage payload to the active Service Worker:

window.addEventListener('load', function() {
    if (navigator.serviceWorker.controller) {
        navigator.serviceWorker.controller.postMessage({'command': 'trimCaches'});
    }
});

Then inside the Service Worker, I can listen for that message event and run my cache-trimming function:

self.addEventListener('message', function(event) {
    if (event.data.command == 'trimCaches') {
        trimCache(pagesCacheName, 35);
        trimCache(imagesCacheName, 20);
    }
});

So what happens is you visit a page, and the caching happens as usual. But then, once the page and all its assets are loaded, a message is fired off and the caches get trimmed.

I’ve updated my Service Worker and it looks like it’s working a treat.

Have you published a response to this? :

Responses

brandonrozek.com

Summary: I rewrote how cache limiting works to address a few problems listed later in this post. Check out the gist for the updated code.

I wrote a function in my previous service worker post to help limit the cache. Here’s a reminder of what it looked like.


var limitCache = function(cache, maxItems) { cache.keys().then(function(items) { if (items.length > maxItems) { cache.delete(items[0]); } })
}

The Problem

Jeremy Keith updated the service worker on his site and noticed that the images has blown past the amount he allocated for it (post). Looking back at my service worker, I noticed that mine has the same shortcoming as well. So what happened? Service workers function in an asynchronous manner. Meaning it can be processing not just one, but many fetch events at the same time. This comes into conflict when there are synchronous instructions such as deleting the first item from the cache which Jeremy describes in his follow up post.

A Solution

Jeremy wrote a function to help trim the cache and asked when it would be appropriate to apply it.


var trimCache = function (cacheName, maxItems) { caches.open(cacheName) .then(function (cache) { cache.keys() .then(function (keys) { if (keys.length > maxItems) { cache.delete(keys[0]) .then(trimCache(cacheName, maxItems)); } }); });
};

And that got me thinking. In what situations is this problem more likely to occur? This particular problem happens when a lot of files are being called asynchronously. This problem doesn’t occur when only one file is being loaded. So when do we load a bunch of files? During page load. During page load, the browser might request css, javascript, images, etc. Which for most websites, is a lot of files. Let’s now move our focus back to the humble script.js. Before, the only role it played with service workers was registering the script. However, if we can get the script to notify the service worker when the page is done loading, then the service worker will know when to trim the cache.


if ('serviceWorker' in navigator) { navigator.serviceWorker.register('https://yourwebsite.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() { if (navigator.serviceWorker.controller != null) { navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); }
});

Why if (navigator.serviceWorker.controller != null)? Service Workers don’t take control of the page immediately but on subsequent page loads, Jake Archibald explains. When the service worker does have control of the page however, we can use the postMessage api to send a message to the service worker. Inside, I provided a json with a “command” to “trimCache”. Since we send the json to the service worker, we need to make sure that it can receive it.


self.addEventListener("message", function(event) { var data = event.data; if (data.command == "trimCache") { trimCache(version + "pages", 25); trimCache(version + "images", 10); trimCache(version + "assets", 30); }
});

Once it receives the command, it goes on to trim all of the caches.

Conclusion

So whenever you download a bunch of files, make sure to run navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); on the main javascript file to trim the cache. A downside to this method is that since Service Workers don’t take control during the first page load, the cache isn’t trimmed until the second page load. If you can find a way to make it so that this event happens in the first page load tell me about it/write a blog post. 🙂 Update: To get the service worker to take control of the page immediately call self.skipWaiting() after the install event and self.clients.claim() after the activate event. Current code for our humble service worker:


var version = 'v2.0.24:'; var offlineFundamentals = [ '/', '/offline/'
]; //Add core website files to cache during serviceworker installation
var updateStaticCache = function() { return caches.open(version + 'fundamentals').then(function(cache) { return Promise.all(offlineFundamentals.map(function(value) { var request = new Request(value); var url = new URL(request.url); if (url.origin != location.origin) { request = new Request(value, {mode: 'no-cors'}); } return fetch(request).then(function(response) { var cachedCopy = response.clone(); return cache.put(request, cachedCopy); }); })) })
}; //Clear caches with a different version number
var clearOldCaches = function() { return caches.keys().then(function(keys) { return Promise.all( keys .filter(function (key) { return key.indexOf(version) != 0; }) .map(function (key) { return caches.delete(key); }) ); })
} /* trims the cache If cache has more than maxItems then it removes the excess items starting from the beginning
*/
var trimCache = function (cacheName, maxItems) { caches.open(cacheName) .then(function (cache) { cache.keys() .then(function (keys) { if (keys.length > maxItems) { cache.delete(keys[0]) .then(trimCache(cacheName, maxItems)); } }); });
}; //When the service worker is first added to a computer
self.addEventListener("install", function(event) { event.waitUntil(updateStaticCache() .then(function() { return self.skipWaiting(); }) );
}) self.addEventListener("message", function(event) { var data = event.data; //Send this command whenever many files are downloaded (ex: a page load) if (data.command == "trimCache") { trimCache(version + "pages", 25); trimCache(version + "images", 10); trimCache(version + "assets", 30); }
}); //Service worker handles networking
self.addEventListener("fetch", function(event) { //Fetch from network and cache var fetchFromNetwork = function(response) { var cacheCopy = response.clone(); if (event.request.headers.get('Accept').indexOf('text/html') != -1) { caches.open(version + 'pages').then(function(cache) { cache.put(event.request, cacheCopy); }); } else if (event.request.headers.get('Accept').indexOf('image') != -1) { caches.open(version + 'images').then(function(cache) { cache.put(event.request, cacheCopy); }); } else { caches.open(version + 'assets').then(function add(cache) { cache.put(event.request, cacheCopy); }); } return response; } //Fetch from network failed var fallback = function() { if (event.request.headers.get('Accept').indexOf('text/html') != -1) { return caches.match(event.request).then(function (response) { return response || caches.match('/offline/'); }) } else if (event.request.headers.get('Accept').indexOf('image') != -1) { return new Response('Offlineoffline', { headers: { 'Content-Type': 'image/svg+xml' }}); } } //This service worker won't touch non-get requests if (event.request.method != 'GET') { return; } //For HTML requests, look for file in network, then cache if network fails. if (event.request.headers.get('Accept').indexOf('text/html') != -1) { event.respondWith(fetch(event.request).then(fetchFromNetwork, fallback)); return; } //For non-HTML requests, look for file in cache, then network if no cache exists. event.respondWith( caches.match(event.request).then(function(cached) { return cached || fetch(event.request).then(fetchFromNetwork, fallback); }) ) }); //After the install event
self.addEventListener("activate", function(event) { event.waitUntil(clearOldCaches() .then(function() { return self.clients.claim(); }) );
});

if ('serviceWorker' in navigator) { navigator.serviceWorker.register('https://brandonrozek.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() { if (navigator.serviceWorker.controller != null) { navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); }
});

# Monday, November 30th, 2015 at 12:00am

brandonrozek.com

Summary: I rewrote how cache limiting works to address a few problems listed later in this post. Check out the gist for the updated code.

I wrote a function in my previous service worker post to help limit the cache. Here’s a reminder of what it looked like.


var limitCache = function(cache, maxItems) { cache.keys().then(function(items) { if (items.length > maxItems) { cache.delete(items[0]); } })
}

The Problem

Jeremy Keith updated the service worker on his site and noticed that the images has blown past the amount he allocated for it (post). Looking back at my service worker, I noticed that mine has the same shortcoming as well. So what happened? Service workers function in an asynchronous manner. Meaning it can be processing not just one, but many fetch events at the same time. This comes into conflict when there are synchronous instructions such as deleting the first item from the cache which Jeremy describes in his follow up post.

A Solution

Jeremy wrote a function to help trim the cache and asked when it would be appropriate to apply it.


var trimCache = function (cacheName, maxItems) { caches.open(cacheName) .then(function (cache) { cache.keys() .then(function (keys) { if (keys.length > maxItems) { cache.delete(keys[0]) .then(trimCache(cacheName, maxItems)); } }); });
};

And that got me thinking. In what situations is this problem more likely to occur? This particular problem happens when a lot of files are being called asynchronously. This problem doesn’t occur when only one file is being loaded. So when do we load a bunch of files? During page load. During page load, the browser might request css, javascript, images, etc. Which for most websites, is a lot of files. Let’s now move our focus back to the humble script.js. Before, the only role it played with service workers was registering the script. However, if we can get the script to notify the service worker when the page is done loading, then the service worker will know when to trim the cache.


if ('serviceWorker' in navigator) { navigator.serviceWorker.register('https://yourwebsite.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() { if (navigator.serviceWorker.controller != null) { navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); }
});

Why if (navigator.serviceWorker.controller != null)? Service Workers don’t take control of the page immediately but on subsequent page loads, Jake Archibald explains. When the service worker does have control of the page however, we can use the postMessage api to send a message to the service worker. Inside, I provided a json with a “command” to “trimCache”. Since we send the json to the service worker, we need to make sure that it can receive it.


self.addEventListener("message", function(event) { var data = event.data; if (data.command == "trimCache") { trimCache(version + "pages", 25); trimCache(version + "images", 10); trimCache(version + "assets", 30); }
});

Once it receives the command, it goes on to trim all of the caches.

Conclusion

So whenever you download a bunch of files, make sure to run navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); on the main javascript file to trim the cache. A downside to this method is that since Service Workers don’t take control during the first page load, the cache isn’t trimmed until the second page load. If you can find a way to make it so that this event happens in the first page load tell me about it/write a blog post. 🙂 Update: To get the service worker to take control of the page immediately call self.skipWaiting() after the install event and self.clients.claim() after the activate event. Current code for our humble service worker:


var version = 'v2.0.24:'; var offlineFundamentals = [ '/', '/offline/'
]; //Add core website files to cache during serviceworker installation
var updateStaticCache = function() { return caches.open(version + 'fundamentals').then(function(cache) { return Promise.all(offlineFundamentals.map(function(value) { var request = new Request(value); var url = new URL(request.url); if (url.origin != location.origin) { request = new Request(value, {mode: 'no-cors'}); } return fetch(request).then(function(response) { var cachedCopy = response.clone(); return cache.put(request, cachedCopy); }); })) })
}; //Clear caches with a different version number
var clearOldCaches = function() { return caches.keys().then(function(keys) { return Promise.all( keys .filter(function (key) { return key.indexOf(version) != 0; }) .map(function (key) { return caches.delete(key); }) ); })
} /* trims the cache If cache has more than maxItems then it removes the excess items starting from the beginning
*/
var trimCache = function (cacheName, maxItems) { caches.open(cacheName) .then(function (cache) { cache.keys() .then(function (keys) { if (keys.length > maxItems) { cache.delete(keys[0]) .then(trimCache(cacheName, maxItems)); } }); });
}; //When the service worker is first added to a computer
self.addEventListener("install", function(event) { event.waitUntil(updateStaticCache() .then(function() { return self.skipWaiting(); }) );
}) self.addEventListener("message", function(event) { var data = event.data; //Send this command whenever many files are downloaded (ex: a page load) if (data.command == "trimCache") { trimCache(version + "pages", 25); trimCache(version + "images", 10); trimCache(version + "assets", 30); }
}); //Service worker handles networking
self.addEventListener("fetch", function(event) { //Fetch from network and cache var fetchFromNetwork = function(response) { var cacheCopy = response.clone(); if (event.request.headers.get('Accept').indexOf('text/html') != -1) { caches.open(version + 'pages').then(function(cache) { cache.put(event.request, cacheCopy); }); } else if (event.request.headers.get('Accept').indexOf('image') != -1) { caches.open(version + 'images').then(function(cache) { cache.put(event.request, cacheCopy); }); } else { caches.open(version + 'assets').then(function add(cache) { cache.put(event.request, cacheCopy); }); } return response; } //Fetch from network failed var fallback = function() { if (event.request.headers.get('Accept').indexOf('text/html') != -1) { return caches.match(event.request).then(function (response) { return response || caches.match('/offline/'); }) } else if (event.request.headers.get('Accept').indexOf('image') != -1) { return new Response('Offlineoffline', { headers: { 'Content-Type': 'image/svg+xml' }}); } } //This service worker won't touch non-get requests if (event.request.method != 'GET') { return; } //For HTML requests, look for file in network, then cache if network fails. if (event.request.headers.get('Accept').indexOf('text/html') != -1) { event.respondWith(fetch(event.request).then(fetchFromNetwork, fallback)); return; } //For non-HTML requests, look for file in cache, then network if no cache exists. event.respondWith( caches.match(event.request).then(function(cached) { return cached || fetch(event.request).then(fetchFromNetwork, fallback); }) ) }); //After the install event
self.addEventListener("activate", function(event) { event.waitUntil(clearOldCaches() .then(function() { return self.clients.claim(); }) );
});

if ('serviceWorker' in navigator) { navigator.serviceWorker.register('https://brandonrozek.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() { if (navigator.serviceWorker.controller != null) { navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); }
});

# Monday, November 30th, 2015 at 12:00am

brandonrozek.com

Summary: I rewrote how cache limiting works to address a few problems listed later in this post. Check out the gist for the updated code. I wrote a function in my previous service worker post to help limit the cache. Here’s a reminder of what it looked like.


var limitCache = function(cache, maxItems) {
 cache.keys().then(function(items) {
 if (items.length > maxItems) {
 cache.delete(items[0]);
 }
 })
}

The Problem

Jeremy Keith updated the service worker on his site and noticed that the images has blown past the amount he allocated for it (post). Looking back at my service worker, I noticed that mine has the same shortcoming as well.

So what happened?

Service workers function in an asynchronous manner. Meaning it can be processing not just one, but many fetch events at the same time. This comes into conflict when there are synchronous instructions such as deleting the first item from the cache which Jeremy describes in his follow up post.

A Solution

Jeremy wrote a function to help trim the cache and asked when it would be appropriate to apply it.


var trimCache = function (cacheName, maxItems) {
 caches.open(cacheName)
 .then(function (cache) {
 cache.keys()
 .then(function (keys) {
 if (keys.length > maxItems) {
 cache.delete(keys[0])
 .then(trimCache(cacheName, maxItems));
 }
 });
 });
};

And that got me thinking. In what situations is this problem more likely to occur? This particular problem happens when a lot of files are being called asynchronously. This problem doesn’t occur when only one file is being loaded.

So when do we load a bunch of files? During page load.

During page load, the browser might request css, javascript, images, etc. Which for most websites, is a lot of files.

Let’s now move our focus back to the humble script.js. Before, the only role it played with service workers was registering the script. However, if we can get the script to notify the service worker when the page is done loading, then the service worker will know when to trim the cache.


if ('serviceWorker' in navigator) {
 navigator.serviceWorker.register('https://yourwebsite.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() {
 if (navigator.serviceWorker.controller != null) {
 navigator.serviceWorker.controller.postMessage({"command":"trimCache"});
 }
});

Why if (navigator.serviceWorker.controller != null)? Service Workers don’t take control of the page immediately but on subsequent page loads, Jake Archibald explains.

When the service worker does have control of the page however, we can use the postMessage api to send a message to the service worker. Inside, I provided a json with a “command” to “trimCache”.

Since we send the json to the service worker, we need to make sure that it can receive it.


self.addEventListener("message", function(event) {
 var data = event.data;
 
 if (data.command == "trimCache") {
 trimCache(version + "pages", 25);
 trimCache(version + "images", 10);
 trimCache(version + "assets", 30);
 }
});

Once it receives the command, it goes on to trim all of the caches.

Conclusion

So whenever you download a bunch of files, make sure to run navigator.serviceWorker.controller.postMessage({"command":"trimCache"}); on the main javascript file to trim the cache. A downside to this method is that since Service Workers don’t take control during the first page load, the cache isn’t trimmed until the second page load. If you can find a way to make it so that this event happens in the first page load tell me about it/write a blog post. :)

Update: To get the service worker to take control of the page immediately call self.skipWaiting() after the install event and self.clients.claim() after the activate event.

Current code for our humble service worker:


var version = 'v2.0.24:';

var offlineFundamentals = [
 '/',
 '/offline/'
];

//Add core website files to cache during serviceworker installation
var updateStaticCache = function() {
 return caches.open(version + 'fundamentals').then(function(cache) {
 return Promise.all(offlineFundamentals.map(function(value) {
 var request = new Request(value);
 var url = new URL(request.url);
 if (url.origin != location.origin) {
 request = new Request(value, {mode: 'no-cors'});
 }
 return fetch(request).then(function(response) { 
 var cachedCopy = response.clone();
 return cache.put(request, cachedCopy); 
 
 });
 }))
 })
};

//Clear caches with a different version number
var clearOldCaches = function() {
 return caches.keys().then(function(keys) {
 return Promise.all(
 keys
 .filter(function (key) {
 return key.indexOf(version) != 0;
 })
 .map(function (key) {
 return caches.delete(key);
 })
 );
 })
}

/*
 trims the cache
 If cache has more than maxItems then it removes the excess items starting from the beginning
*/
var trimCache = function (cacheName, maxItems) {
 caches.open(cacheName)
 .then(function (cache) {
 cache.keys()
 .then(function (keys) {
 if (keys.length > maxItems) {
 cache.delete(keys[0])
 .then(trimCache(cacheName, maxItems));
 }
 });
 });
};


//When the service worker is first added to a computer
self.addEventListener("install", function(event) {
 event.waitUntil(updateStaticCache()
 .then(function() { 
 return self.skipWaiting(); 
 })
 );
})

self.addEventListener("message", function(event) {
 var data = event.data;
 
 //Send this command whenever many files are downloaded (ex: a page load)
 if (data.command == "trimCache") {
 trimCache(version + "pages", 25);
 trimCache(version + "images", 10);
 trimCache(version + "assets", 30);
 }
});

//Service worker handles networking
self.addEventListener("fetch", function(event) {

 //Fetch from network and cache
 var fetchFromNetwork = function(response) {
 var cacheCopy = response.clone();
 if (event.request.headers.get('Accept').indexOf('text/html') != -1) {
 caches.open(version + 'pages').then(function(cache) {
 cache.put(event.request, cacheCopy);
 });
 } else if (event.request.headers.get('Accept').indexOf('image') != -1) {
 caches.open(version + 'images').then(function(cache) {
 cache.put(event.request, cacheCopy);
 });
 } else {
 caches.open(version + 'assets').then(function add(cache) {
 cache.put(event.request, cacheCopy);
 });
 }

 return response;
 }

 //Fetch from network failed
 var fallback = function() {
 if (event.request.headers.get('Accept').indexOf('text/html') != -1) {
 return caches.match(event.request).then(function (response) { 
 return response || caches.match('/offline/');
 })
 } else if (event.request.headers.get('Accept').indexOf('image') != -1) {
 return new Response('Offlineoffline', { headers: { 'Content-Type': 'image/svg+xml' }});
 } 
 }
 
 //This service worker won't touch non-get requests
 if (event.request.method != 'GET') {
 return;
 }
 
 //For HTML requests, look for file in network, then cache if network fails.
 if (event.request.headers.get('Accept').indexOf('text/html') != -1) {
 event.respondWith(fetch(event.request).then(fetchFromNetwork, fallback));
 return;
 }

 //For non-HTML requests, look for file in cache, then network if no cache exists.
 event.respondWith(
 caches.match(event.request).then(function(cached) {
 return cached || fetch(event.request).then(fetchFromNetwork, fallback);
 })
 ) 
});

//After the install event
self.addEventListener("activate", function(event) {
 event.waitUntil(clearOldCaches()
 .then(function() { 
 return self.clients.claim(); 
 })
 );
});

if ('serviceWorker' in navigator) {
 navigator.serviceWorker.register('https://brandonrozek.com/serviceworker.js', {scope: '/'});
}
window.addEventListener("load", function() {
 if (navigator.serviceWorker.controller != null) {
 navigator.serviceWorker.controller.postMessage({"command":"trimCache"});
 }
});

# Sunday, December 27th, 2015 at 7:01am