title: An Offline Experience with Service Workers
url: https://brandonrozek.com/2015/11/service-workers/
hash_url: 8bd7ed4d76
I’m excited to say that I’ve written my first service worker for brandonrozek.com. What is a service worker? A service worker provides an extra layer between the client and the server. The exciting part about this is that you can use service workers to deliver an offline experience. (Cached versions of your site, offline pages, etc.)
Service workers are currently supported in Chrome, Opera, and Firefox nightly. You don’t have to worry too much about browser support because the Service Worker spec was written in a progressively enchanced way meaning it won’t break your existing site :)
You need HTTPS to be able to use service workers on your site. This is mainly for security reasons. Imagine if a third party can control all of the networking requests on your site?
If you don’t want to go out and buy a SSL Certificate, there are a couple free ways to go about this.
1) Cloudflare
2) Let’s Encrypt
Service workers are promise heavy. Promises contain a then clause which runs code asynchronously. If you’re not accustomed to this idea please check out this post by Nicolas Bevacqua.
Now onto making the service worker! If you want to skip to the final code scroll down to the bottom. Unless you don’t like my syntax highlighting, then you can check out this gist.
Place service-worker.js
on the root of your site. This is so the service worker can access all the files in the site.
Then in your main javascript file, register the service worker.
if (navigator.serviceWorker) { navigator.serviceWorker.register(‘/serviceworker.js’, {
scope: '/'
}); }
The first time the service worker runs, it emits the install
event. At this time, we can load the visitor’s cache with some resources for when they’re offline.
Every so often, I like to change up the theme of the site. So I have version numbers attached to my files. I would also like to invalidate my cache with this version number. So at the top of the file I added
var version = ‘v2.0.24:’;
Now, to specify which files I want the service worker to cache for offline use. I thought my home page and my offline page would be good enough.
var offlineFundamentals = [
'/', '/offline/'
];
Since cache.addAll()
hasn’t been implemented yet in any of the browsers, and the polyfill implementation didn’t work for my needs. I pieced together my own.
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); }); })) })
};
Let’s go through this chunk of code.
‘v2.0.24:fundamentals’
offlineFundamental
‘s URLsNow we call it when the install
event is fired.
self.addEventListener(“install”, function(event) {
event.waitUntil(updateStaticCache())
})
With this we now cached all the files in the offlineFundamentals array during the install step.
Since we’re caching everything. If you change one of the files, your visitor wouldn’t get the changed file. Wouldn’t it be nice to remove old files from the visitor’s cache?
Every time the service worker finishes the install step, it releases an activate
event. We can use this to look and see if there are any old cache containers on the visitor’s computer.
From Nicolas’ code. Thanks for sharing :)
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); }) ); })
}
Call the function when the activate
event fires.
self.addEventListener(“activate”, function(event) {
event.waitUntil(clearOldCaches())
});
The cool thing about service worker’s is that it can handle file requests. We could cache all files requested for offline use, and if a fetch for a resource failed, then the service worker can look for it in the cache or provide an alternative.
This is a large section, so I’m going to attempt to break it down as much as I can.
If the visitor started browsing all of the pages on my site, his or her cache would start to get bloated with files. To not burden my visitors, I decided to only keep the latest 25 pages and latest 10 images in the cache.
var limitCache = function(cache, maxItems) {
cache.keys().then(function(items) { if (items.length > maxItems) { cache.delete(items[0]); } })
}
We’ll call it later in the code.
Every time I fetch a file from the network I throw it into a specific cache container. ‘pages’
for HTML files, ‘images’
for CSS files, and ‘assets’
for any other file. This is so I can handle the cache limiting above easier.
Defined within the fetch
event.
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).then(function() { limitCache(cache, 25); }) }); } else if (event.request.headers.get('Accept').indexOf('image') != -1) { caches.open(version + 'images').then(function(cache) { cache.put(event.request, cacheCopy).then(function() { limitCache(cache, 10); }); }); } else { caches.open(version + 'assets').then(function add(cache) { cache.put(event.request, cacheCopy); }); } return response; }
There are going to be times where the visitor cannot access the website. Maybe they went in a tunnel while they were riding a train? Or maybe your site went down.
I thought it would be nice for my reader’s to be able to look over my blog posts again regardless of an internet connection. So I provide a fall-back.
Defined within the fetch
event.
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('<svg width="400" height="300" role="img" aria-labelledby="offline-title" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title id="offline-title">Offline</title><g fill="none" fill-rule="evenodd"><path fill="#D8D8D8" d="M0 0h400v300H0z"/><text fill="#9B9B9B" font-family="Helvetica Neue,Arial,Helvetica,sans-serif" font-size="72" font-weight="bold"><tspan x="93" y="172">offline</tspan></text></g></svg>', { headers: { 'Content-Type': 'image/svg+xml' }}); } }
First off, I’m only handling GET requests.
if (event.request.method != ‘GET’) {
return;
}
For HTML files, grab the file from the network. If that fails, then look for it in the cache.
Network then cache strategy
if (event.request.headers.get(‘Accept’).indexOf(‘text/html’) != -1) {
event.respondWith(fetch(event.request).then(fetchFromNetwork, fallback)); return; }
For non-HTML files, follow this series of steps
Cache then network strategy
event.respondWith(
caches.match(event.request).then(function(cached) { return cached || fetch(event.request).then(fetchFromNetwork, fallback); }) )
For different stategy’s, take a look at Jake Archibald’s offline cookbook.
With all of that, we now have a fully functioning offline-capable website! I wouldn’t be able to implement this myself if it wasn’t for some of the awesome people I mentioned earlier sharing their experience. So share, share, share!
With that sentiment, I’ll now share the full code for service-worker.js
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); }) ); })
}
/*
limits the cache If cache has more than maxItems then it removes the first item in the cache
*/ var limitCache = function(cache, maxItems) {
cache.keys().then(function(items) { if (items.length > maxItems) { cache.delete(items[0]); } })
}
//When the service worker is first added to a computer self.addEventListener(“install”, function(event) {
event.waitUntil(updateStaticCache())
})
//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).then(function() { limitCache(cache, 25); }) }); } else if (event.request.headers.get('Accept').indexOf('image') != -1) { caches.open(version + 'images').then(function(cache) { cache.put(event.request, cacheCopy).then(function() { limitCache(cache, 10); }); }); } 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('<svg width="400" height="300" role="img" aria-labelledby="offline-title" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title id="offline-title">Offline</title><g fill="none" fill-rule="evenodd"><path fill="#D8D8D8" d="M0 0h400v300H0z"/><text fill="#9B9B9B" font-family="Helvetica Neue,Arial,Helvetica,sans-serif" font-size="72" font-weight="bold"><tspan x="93" y="172">offline</tspan></text></g></svg>', { 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())
});