Service Workers are a tool given to JavaScript developers to write code handling caching, among other things. It allows you to write code that can control any outgoing requests.
I've written a straight-forward, simple Service Worker. It has a lot of comments to get you accustomed with the basic principles. Let's walk through the code and get our feet wet with Service Workers!
<script> if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js'); } </script>
That’s right, nothing more than one function call is needed to unlock the powerful features of Service Workers. To avoid breaking older browsers, the registration is best wrapped inside a feature detection conditional.
addEventListener('install', (e) => { e.waitUntil(self.skipWaiting()); });
The first block of code listens for the install
event. This is an event that gets triggered when the browser has loaded the Service Worker and tries to install it. At this moment, any previously installed Service Workers are still doing their thing. Inside the install
event handler, you can make the browser wait for certain things to happen by passing a Promise to the event.waitUntil
method.
self.skipWaiting()
tells the browser that it shouldn’t wait for previous Service Workers to properly detach and just move on with the installation already. This forces the Service Worker to become the active Service Worker, regardless of any other Service Workers.
addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
When the Service Worker is installed, it will get activated. This is the right moment to — you guessed it — activate the Service Worker.
self.clients.claim()
allows the Service Worker to take over all existing clients. By default, the Service Worker would only become active on the next page load. Claiming all active clients connects the Service Worker to the active browser session.
if (method !== 'GET' && method !== 'HEAD') { return; }
With great power, comes great responsibility. To make sure the Service Worker doesn't accidentally mess with anything critical, it will short-circuit if the request is not a GET
request.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith((async function() { const response = fetch(request); return response; })()); });
The Service Worker can send any arbitrary response to any request by passing a resolving Promise to the event with e.respondWith()
. That's the hook provided to web developers to run custom code on the incoming request & determine what to do with it.
When a request comes in, there are a lot of different strategies to handling a request. You can work offline only, network only, serve from cache and use the network as a fallback, or vice versa.
The example uses two caching strategies. For HTML documents, we want to get the freshest content possible. The Service Worker will always try to fetch the latest data over the network. For all other assets, a cached version is sufficient. As an extra, the Service Worker will go ahead and try to fetch a new version in the background.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith((async function() { const response = fetch(request); return response.catch(async function() { const cached = await caches.match(request); return cached || response; }); })()); });
The Service Worker tries to fetch()
the request from the network. If the network request resolves, the Service Worker hands that resolved request back to the browser. If it doesn’t, however, it’ll find the request in the cache: await caches.match(request)
. The Service Worker will try and find a cached version of the request. When a cached response is found, the Service Worker will pass it to the browser. If the network request failed and there is nothing to fall back on in the cache, the Service Worker returns the failed request for the browser to handle.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith( (async function() { const cached = await caches.match(request); return cached || fetch(request); })() ); });
When a request arrives in the Service Worker, before letting it go the network, the Service Worker looks for it in its cache. If a cached response is found, that response will be returned. If the cached version is undefined
, the Service Worker lets the request go through to the network.
However, both implementations of the caching strategies discussed above will never add anything to the cache. We're doing that because for every single request, we want the Service Worker to revalidate the fetched resource in the background.
Keep in mind that the Service Worker is not the only cache that a browser has. For example, if your CSS file has a Cache-Control: immutable
HTTP header and the Service Worker fetches a new version, that request will still hit the disk cache instead of downloading the resource.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith( (async function() { const response = fetch(request); e.waitUntil( (async function() { const clone = (await response).clone(); const cache = await caches.open(cacheName); await cache.put(request, clone); })() ); // … Handle the request itself })() ); });
The response isn’t actually delayed by using e.waitUntil
. Instead, it is a way to communicate to the browser that your Service Worker is still doing some work. You can pass Promises or async functions to e.waitUntil
and the Service Worker will only be terminated once they are all resolved.
The Service Worker here waits for the network request to finish. When it does, it stores the response in the cache.
Note that a network response can be used only once. That's why the Service Worker clones the response before storing it in the cache: const clone = (await response).clone()
.
The major use case for Service Workers is of course the impressive performance improvements it offers to your end users. In a lot of cases, we can assume that serving from disk will be a lot faster than having to go over the network for every request. That is only the tip of the iceberg: the flexibility of Service Workers gives you as a developer a lot to work with. In the example we’re discussing, there are two additional performance improvements.
Service Workers live independently off browser sessions and are generally considered more persistent. However, with device resources being limited, it is never a given that a Service Worker will already be active to act on a HTTP request. It turned out that sometimes, booting a Service Worker so it can work with a HTTP request added noticable overhead to the response time.
A solution for that was Navigation Preloads. This is an unofficial, experimental technology that currently only works in Webkit-browsers. However, it’s rather straight-forward to add to a Service Worker and it can provide an undeniable speed improvement, so why not add it?
Adding support for Navigation Preloads has two parts to it. First, in the activate
handler, the Service Worker has to enable Navigation Preload support.
addEventListener('activate', (e) => { e.waitUntil( (async function() { if (self.registration.navigationPreload) { await self.registration.navigationPreload.enable(); } })() ); });
That's enough to inform the browser that you’re planning on using the preloaded response, which in turn will make the browser do navigation preloads. It’s important to actually consume the preloaded response if the Service Worker enables the support. Failing to do so would result in duplicate network requests.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith( (async function() { const response = Promise.resolve(e.preloadResponse).then( (preloaded) => preloaded || fetch(request) ); return response; })() ); });
If the browser supports Navigation Preloads, response
will consume the preloaded response once it’s completely finished. In browsers that don’t support Navigation Preloads, e.preloadResponse
will be undefined
and a regular network request will be started.
We’ve all been in situations where our internet connection was terribly slow or non-existing at all. The worst of all is the lie-fi connection when your device is connected to a network, but in reality, no data is coming down the wire.
A possible solution for dreadingly slow network responses could be to fall back on cached versions anyway. The Service Worker can add a timeout to the network request and decide to check the cache if the network is taking too long.
addEventListener('fetch', (e) => { const request = e.request; e.respondWith( (async function() { return Promise.race([ fetch(request), new Promise((_, reject) => { setTimeout(() => reject(), 2000); }) ]).catch(async function() { const cached = await caches.match(request); return cached || response; }); })() ); });
The Service Worker starts two Promises at the same time. One is the actual network request, the other is a timer that will reject()
after two seconds. Promise.race
is a method that returns a Promise that will resolve or reject when the first Promise of the ones passed does so.
If the response returns within two seconds, the fresh response will be passed on to the browser. However, if the timer rejects first after two seconds, the Service Worker will look for a cached response. If nothing is found in the cache, the (still pending) network request is returned anyway for the browser to handle.
Using possibly stale content instead of a forever hanging network request is a small trade-off to make for an impactful user experience improvement.
That’s about it! There is a lot more to cover about Service Workers, depending on what you are trying to do with it. I am convinced, however, that this is a good start for any simple site or blog that wants to leverage the offline capabilities and caching possibilities that come with Service Workers.
And again, find the full code on GitHub.
Back to Build Progressive