Separating web apps — Service Workers

Separating web apps — Service Workers
Photo by Fancycrave on Unsplash

Originally published on Medium - 27th December 2018

So as a bit of a followup to my PWA article I wrote a while back, I thought I’d do a bit of a write-up on the main part that separates a regular web page from a progressive web app — service workers.

A service worker essentially is a proxy between the network and your web page. What do you do with this? Lots of things. The obvious is just caching assets for offline access. But we can go way beyond that. We could have a default image for if one isn’t found on the server (rather than just the normal broken image icon), we can replace images on the fly depending on the browser (eg. using WebP when supported, and falling back to PNG when not).

Service Workers also support push notifications. Traditionally one of the main reasons to go to a native or hybrid app over a web page.

So what are the limitations?

  • Because the service worker runs on an entirely different thread to the main JavaScript thread, it means we don’t have access to the DOM, which also means global variables are a no-go as they typically sit on the window object.
  • Service workers only support async style calls (think Promises) rather than synchronous ones (like XHR and LocalStorage).
  • The service worker is only initially downloaded after the first page is loaded, so if you’re intercepting calls, you will miss the first ones.

So some other things to consider are that the service worker will be downloaded every 24 hours, or sooner depending on situations. Service workers only work on https connections or via localhost (which is great for development)

Enough writing, let’s get into some examples.

Some initial setup. I’ll assume you’re already including JavaScript that you’ve built yourself (ie. your app code).

When we initially load your app, we need to make a basic call to register the service worker.

if (navigator.serviceWorker) {
    navigator.serviceWorker.register('service_worker.js', {
        scope: '/'
    });
}

So what are we doing here? For starters we make sure that service workers are available. After that, we’re calling register, which as parameters takes a filename, and options. In this case, we’re only specifying the scope to be at the root level. So what this lets us do is have multiple service workers on a site depending on your path.

Ok, now let’s get into the meat of this. We’ll start with a very basic example of pre-caching your assets.

const CACHE_NAME = 'static';

self.addEventListener('install', event => {
    async function onInstall() {
        const cache = await caches.open(CACHE_NAME);
        await cache.addAll([
            'images/Momenton.png'
        ]);
        return cache;
    }

    event.waitUntil(onInstall());
});

self.addEventListener('fetch', event => {
  const request = event.request;
  const url = new URL(request.url);
  event.respondWith(async function () {
    const cache = await caches.open(CACHE_NAME);
    const cachedResponse = await cache.match(url);
    if (cachedResponse) {
        return cachedResponse;
    }
    return fetch(event.request);
  }
});

A little more complex here, but easily explained. We’re handling two events. “install” and “fetch”.

Install occurs when the service worker is downloaded from the server and installed into the browser. This is called every time the service worker is updated (ie. every 24 hours or sooner).

In the install handler, we define a local onInstall async method, open a named cache (or create if it doesn’t exist) and add an array of filenames to grab from the server. This is the main part of your pre-caching.

The fetch handler is just as simple. Fetch occurs whenever a call to the server is made. This is your main proxy function.

Let’s have a bit of fun now.

const CACHE_NAME = 'static';

self.addEventListener('install', event => {
    async function onInstall() {
        const cache = await caches.open(CACHE_NAME);
        await cache.addAll([
            'images/Dog.jpg'
        ]);
        return cache;
    }

    event.waitUntil(onInstall());
});

self.addEventListener('fetch', event => {
    const request = event.request;
    const url = new URL(request.url);

    if (url.pathname.endsWith('Cat.jpg')) {
        event.respondWith(async function () {
            const cache = await caches.open('static');
            url.pathname = 'images/Dog.jpg'; // Dogs are better than cats
            event.request.URL = url;
            const cachedResponse = await cache.match(url);
            if (cachedResponse) {
                return cachedResponse;
            }
            return fetch(event.request);
        }());
    }
});

So any time we request Cat.jpg, we’re returning Dog.jpg (because Dogs are clearly better than Cats) which we have already pre-cached.

The downside to doing this in the service worker is that the first time you load the page, it will download Cat.jpg as the service worker won’t have been installed yet.

Ok, something more practical, how about displaying your own custom image missing logo for any broken images?

const FALLBACK =
    `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
        width="50px" height="31.579px" viewBox="0 0 50 31.579" enable-background="new 0 0 50 31.579" xml:space="preserve">
        <g>
            <line fill="none" stroke="#F45389" stroke-linecap="round" stroke-linejoin="round" x1="29.895" y1="20.474" x2="20.104" y2="30.265"/>
            <line fill="none" stroke="#F45389" stroke-linecap="round" stroke-linejoin="round" x1="29.895" y1="30.265" x2="20.104" y2="20.474"/>
            <path fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" d="M33.34,30.468h12.741
                c1.937-1.846,3.151-4.445,3.151-7.332c0-4.455-2.879-8.23-6.874-9.588C40.706,6.422,34.324,1.111,26.699,1.111
                c-5.999,0-11.227,3.288-13.991,8.156c-6.599,0.035-11.94,5.393-11.94,12c0,3.697,1.672,6.998,4.301,9.201H16.66"/>
        </g>
    </svg>`;

self.addEventListener('install', event => {
    async function onInstall() {
        const cache = await caches.open('static');
        await cache.addAll([
            'images/Momenton.png'
        ]);
        return cache;
    }

    event.waitUntil(onInstall());
});

self.addEventListener('fetch', event => {
    event.respondWith(networkOrCache(event.request).catch(function () {
        return useFallback();
    }));
});

async function useFallback() {
    return new Response(FALLBACK, {
        headers: {
            'Content-Type': 'image/svg+xml'
        }
    });
}

async function networkOrCache(request) {
    try {
        const response = await fetch(request);
        return response.ok ? response : fromCache(request);
    } catch {
        return fromCache(request);
    }
}

async function fromCache(request) {
    const cache = await caches.open('static');
    const matching = await cache.match(request);
    if (matching) {
        return matching;
    }
    throw 'request-not-in-cache';
}

Quite a bit more to this one, but very similar to the previous example.

We define our fallback image, in this case we embed an SVG into our service worker.

Our install function is still exactly the same, and the cache here could be removed if we want.

Next we have modified our fetch function. We look at the network first, so we have the most up to date info, then the cache if it isn’t found on the network (we may be offline), then failing that, we throw an error and return our embedded SVG.

This only scratches the surface of what we can do with service workers, there’s lots of resources online to go further with this, one of my favourites being https://serviceworke.rs which has some nice pre-written recipes to use.

Mastodon