More PWA to Ya! (Progressive Web Apps, Part 1)

Image of power source at sunset

It’s project kickoff time, and you’re having a conversation with your client about what form the application will take:

Client: I’m thinking mobile app. Our users will definitely be using this on the go.
Dev: Sure, we can do a native mobile-
Client: Mind you, we’ll want a desktop version too. We’ll need to use it from the office.
Dev: Okay, well, a responsive web app-
Client: One of our priorities is definitely ease of access – we’ll need the app accessible from the home screen, ’cause who has time for typing in URLs, amirite? We’ll also want it to be useable offline, whenever people want to.
Dev: Ye-yeah, no problem, we can wrap your web app in a webview, bundle it up as a native app, and-
Client: Yeah, cool. So they’ll just be able to go to the site and install the app, right?
Dev: Well, no, they’ll have to download it from the appropriate App Store.
Client: Eh, that’s a no-go – this is internal only, we can’t have it showing up in the app stores. Didn’t I make that clear from the start?
Dev: …

The term your client was looking for is Progressive Web App – an application that acts like a responsive web app when accessed from the browser on any device, but can be installed to mobile devices like a native application. The link above makes the case for PWAs, so we won’t belabour the point – if you’re still here, it’s because you’re convinced it’s time to build a PWA.

Let’s dig into the details. We’re going to assume you have, or are building, a responsive or mobile-focused web application, and want to convert it to a PWA. Keep in mind that, like wrapping a webapp in a webview, all the heavy lifting is still done by you, the developer, in CSS, HTML and JS – there’s no PWA magic to make it look ‘native’.

Well, actually, there is a little magic. We’ll get to that next time.

Part 1 will focus on implementing a PWA the standards-compliant way. In Part 2, we’ll address the ‘little bit of magic’ PWA’s can have on Android to appear more native, and PWA’s on iOS Safari, because it always has to be a special snowflake.

Let’s begin.

Service, Please

Familiar with Service Workers? If not, get ready to do some reading on them – Service Workers are the clockwork that make PWAs tick.

Mozilla’s summary of Service Workers make it clear how this is so – and also, how complex a Service Worker can be:

Service workers essentially act as proxy servers that sit between web applications, and the browser and network (when available). They are intended to (amongst other things) enable the creation of effective offline experiences, intercepting network requests and taking appropriate action based on whether the network is available and updated assets reside on the server. They will also allow access to push notifications and background sync APIs.

If you’re familiar with Web Workers, you’re about half-way there – Service Workers run on their own ‘thread’ (actual implementation details are up to the browser, of course), and have no access to the DOM, like a Web Worker. They have significantly more power than a web worker, however, particularly in terms of interacting with network requests; and, as a result, have more requirements to meet.

Let’s run down the checklist, and then we’ll get into some implementation details. For a PWA’s Service Worker you will need:

  • A secure context – Service Workers must be run from a TLS-secured domain (https), because they can be Men-in-the-middle on every request from the browser for a given domain. The localhost special-case domain is the only exception to this rule, for the sake of development.
  • A full list of the items you need to cache (for the caching and serving from cache functionality of a Service Worker, which is a requirement to have your application considered a PWA). Wildcards can’t be used – you need to give the full (relative) path of the resource you want cached. If you’re familiar with the Application Cache API this restriction will be familiar to you. For your application to be considered a PWA, at least the start url must completely load when the user is offline.
  • A means of serving the Service Worker from the root of the path that you want the Service Worker to have control over – so, if you want the Service Worker to be able to control and serve resources for your entire application, and your application is at https://app.example.com/, you will need to be able to serve the Service Worker from / (as opposed to, e.g. /static/js/workers/ – if you serve from there, the only resources the Service Worker will be able to control will be those under that path).

Additional checklist items for a PWA include:

  • A responsive (or mobile-focused) design.
  • Quick initial load – Google, the company behind the original PWA spec (you may have heard of them), strongly suggests that your start url load under 10 seconds on a simulated 3G network – so no loading a half-dozen affiliate advertisements.
  • You will want to consider making your application a SPA – it’s generally a good fit for this use case, especially if it allows you to cache more up-front, or trim down the number of bits your application needs to transfer over the network.

Looking for a Hard Worker, Room and Board Provided

First things first, let’s set up our web server to serve that Service Worker (lot of variations on ‘serve’ in that sentence) from the root of our domain, so we can control all resources and requests therein. We’re going to assume you’re using Python and Django in the example below, but the principle will always be the same.

First, we’ll create a service worker named CacheWorker.js in our static directory, under a serviceWorkers directory. Then, we can create the view to serve this worker (and any others we might want to serve with potential control over all requests):

from django.conf import settings

def serve_worker(request, worker_name):
    """
    Serve the requested service worker from the appropriate location in the static files.
    We need to serve the worker this way in order to allow it access to requests made against the
    root - whatever /sub/dir the worker ends up getting served from is the only location it will
    have visibility on, so serving from / is the only way to ensure the worker has visibility on all
    requests. Only a-zA-Z-_ characters can appear in the service worker name.

    :param request:
    :param worker_name:
    :return:
    """
    worker_path = path.join(settings.STATIC_ROOT, 'serviceWorkers', "{}.js".format(worker_name))
    try:
        with open(worker_path, 'r') as worker_file:
            return HttpResponse(worker_file, content_type='application/javascript')
    except IOError:
        return HttpResponseNotFound()

Next, in urls.py, we’ll add the route to this view:

urlpatterns = [
    # ...other patterns...
    url(r'^worker-(?P[a-zA-Z\-_]+).js$', views.serve_worker, name='serve_worker'),
    # ...other patterns...
]

Now, assuming our domain was https://app.example.com, a request to https://app.example.com/worker-cacheWorker.js will return our worker script.

Putting Your Workers in their Place

Now that we’re serving our worker script from the desired location, we need to tell the browser that it should be requesting said worker script, and installing it as a Service Worker.

To this effect, we will want to use the ServiceWorkerContainer API to register our service worker. Of course, since our PWA is a progressive enhancement, we will check to ensure that the browser actually supports Service Workers before we try and install it – your application should have some fallback behaviour when it encounters a browser that doesn’t.

/**
 * Install service workers in those browsers which support them.
 */
(function(window){
    var serviceWorkers = {
        "IMMEDIATE": [],
        "LOAD": ['cacheWorker'],
        "DELAY": []
    };

    /**
     * Attempt to register the worker, and log either the success or failure to the console.
     * @param {String} worker
     */
    function registerWorker(worker){
        window.navigator.serviceWorker.register('/worker-'+worker+'.js').then(function(reg){
            console.log('Registration successful for worker '+worker+', with scope: ' + reg.scope);
        }, function(error){
            console.log('Service Worker registration failed for worker: ', worker, error);
        });
    }

    /**
     * Handle messages sent to the main thread by Service Workers.
     * @param event
     */
    function handleMessage(event){
        console.log("TODO: Your app should do something with the event data sent by the worker.", event.data.message, event.data.data);
    }

    // Check for ServiceWorker support.
    if ('serviceWorker' in window.navigator){
        // Listen for messages broadcasted by any service worker
        window.navigator.serviceWorker.addEventListener('message', handleMessage);

        /*
        * For each service worker, consider their priority queue.
        * Workers in the 'IMMEDIATE' queue are registered as soon as we can - this is useful if,
        * for example, we need to immediately be able to intercept requests.
        * Workers within queue 'LOAD' are registered after document load - this is the time to start caching
        * resources, for example, without contending with the browser for bandwidth.
        * Workers in queue 'DELAY' are registered after the application lets us know explicitly that now is a
        * good time. How your application goes about doing this is up to you. This last category
        * is good for workers that are going to be carrying out long-term activities, like
        * long-polling a server.
        */
        serviceWorkers.IMMEDIATE.forEach(registerWorker);

        window.addEventListener('load', function(){
            serviceWorkers.LOAD.forEach(registerWorker);
        });

        window.addEventListener('yourCustomDelayEvent', function(){
            serviceWorkers.DELAY.forEach(registerWorker);
        });
    }
})(window);

Cache Me, I’m Falling

So, we have our server sending our cacheWorker file along properly, and we have the browser registering the service worker, and downloading and installing our script. That’s great – except, our cacheWorker script is empty, so it doesn’t do anything. Let’s fix that.

/**
 * Service worker intended for caching and serving files when the application is offline,
 * to meet the requirements for a PWA.
 * @author Christopher Keefer
 */
var cacheVersion = 1,
    staticCache = 'static-cache-v'+cacheVersion,
    cacheableResources = [
        // Root - This MUST be in the cacheable resources for a PWA!
        '/',
        // Images
        '/static/img/yourLogo.png',
        //... any other static image resources your application will need ...
        // CSS
        '/static/css/yourapp.min.css',
        // ... any other styling your app will need, order doesn't matter ....
        // Fonts
        '/static/css/fonts/roboto/roboto-regular.woff2',
        // ... any other fonts ...
        // JS
        '/static/js/yourapp.min.js'
        // ... any other JS - as with the other entries, the order you specify here doesn't matter,
        // the files will be loaded in the order you indicate in your HTML document. ...
    ];

/**
 * On install of this worker, add all cacheableResources to the staticCache.
 * Note that workers will be (re-)installed when they have changed (byte-wise comparison)
 * from the last worker encountered with the registered url (see the installation of workers, above),
 * which can be as simple as changing the cacheVersion number to point to a new 'version' of the cache.
 * You will want to update that cacheVersion number each time you change any of the cached resources.
 * Doing so will cause the worker to re-request and re-cache the cacheableResources, which is how we
 * will refresh cached resources for the application.
 */
self.addEventListener('install', function(event){
    event.waitUntil(
        caches.open(staticCache).then(function(cache){
            return cache.addAll(cacheableResources);
        }).then(function(){
            // Take control of the client as soon as we're installed
            // and the cache has been updated.
            return self.skipWaiting();
        })
    );
});

/**
 * On activation of this service worker, delete old caches. Note that
 * we return a promise that resolves when all promises returned by
 * the delete calls within it resolve.
 * Once we've deleted the old cache, we need to let the clients know that
 * a new service worker (with a new cache) has taken over, and they'll
 * need to reload in order to get the newly cached resources, via
 * postMessage.
 * @param event
 */
self.addEventListener('activate', function(event){
    event.waitUntil(
        caches.keys().then(function(cacheNames){
            return Promise.all(
                cacheNames.map(function(cacheName){
                    if (cacheName !== staticCache){
                        return caches.delete(cacheName);
                    }
                })
            );
        }).then(function(){
            return self.clients.matchAll().then(function(clients){
                return Promise.all(clients.map(function(client){
                    return client.postMessage({message:'needs-reload'});
                }));
            });
        })
    );
});

/**
 * Intercept network requests so that we can serve the requested resource from the
 * cache, if we have it, or otherwise defer to the network.
 */
self.addEventListener('fetch', function(event){
    // Workaround for Chromium bug that makes ignoring the search
    // parameter very slow when matching the request against the
    // cached values: https://bugs.chromium.org/p/chromium/issues/detail?id=682677.
    // Your application may not need this - or hey, it may even be fixed by the time
    // you're reading this!
    var hasSearch = (event.request.url.indexOf('?') !== -1);

    event.respondWith(
        caches.match(event.request, {
            ignoreSearch: hasSearch
        }).then(function(response){
            return response || fetch(event.request);
        })
    );
});

So, that’s a lot to take in, but the comments above should help. Let’s break down a few of the more complex bits:

Install

So, you’ll notice that we have add an event listener for ‘install’. This is the event that gets fired when a new version of the Service Worker is installed – whether for the first time, or because the Service Worker has changed in some way. The browser does a byte-wise comparison of the downloaded script, so any change will trigger a re-install of the script.

Often, you won’t need to change anything in the script itself, but some of the cached resources have changed, and we need to tell the browser that we want to refresh the cache. In order to do this, we’ll change the cacheVersion number, which will trigger the browser to re-install the Service Worker, triggering our install event, and allowing us to re-downloand and cache all of the updated resources.

Activate

After that, we have a listener on the ‘activate’ event. This gets triggered when our service worker takes control of the context – once it becomes ‘active’. You’ll notice in our ‘install’ event that we’re calling self.skipWaiting();. The normal behaviour is that when a new Service Worker is installed, it doesn’t take over from the old Service Worker until the page is refreshed. This could be fine for your case, but in many cases, we’ll want to start serving the new resources right away, so we tell the browser to skip waiting for this Service Worker, and activate it immediately, allowing it to take over from the old Service Worker (if any).

In the activate event listener, we then clear out all of our old cache versions, leaving just the specified cache available. You’ll want to take a look at the Cache Interface for more details here.

Finally, once we’ve cleared out the old caches, we post a message back to main thread of any clients we have control over that they should reload, since the cache has been updated (remember the handleMessage function in the install workers script?). What you want to do with this message will depend on your application, but it could be as simple as calling location.reload() to get the new resources from the Service Worker.

Fetch

And here’s where the actual offline-enabling functionality happens. You’ll want to take a look at the Fetch API for more details on fetch, but the point here is that we’re intercepting all requests from the client (that’s the browser main thread for our origin), and checking the requested resource name against our cache. If we have the resource, we return it from our cache, and otherwise we pass the request through to the network.

Two things are worth noting here:

  1. Our fetch intercept catches all requests – not just XMLHTTPRequests, for example, but every single request the main thread makes of the network. There’s a lot that can be done with this – we’re just scratching the surface here.
  2. The ‘serve from cache if available, and otherwise pass to the network’ is just one potential model for how we can handle caching and serving resources with the Service Worker – see this article on Caching File with Service Worker for some alternative approaches.

Are We There Yet?

Pretty much! Assuming you can mark the other items off the checklists above, your application should now be ready to serve itself as a PWA. To confirm this, you can use the Lighthouse Extension from Google to test your site, and confirm that all is as it should be.

While this gives you a PWA suitable for, say, desktop use, there’s still a few pieces to the puzzle before we’re ready to be assigned real estate on the home screens of Android or iOS devices – we’ll be going into that next time.

Header image by kappuru, licensed under Creative Commons BY-NC-ND 2.0.

Christopher Keefer

Christopher Keefer

Christopher Keefer is a Senior Software Engineer at Art+Logic. He generally spends his spare time on the computer too, so there isn't much hope for him.
Christopher Keefer

Latest posts by Christopher Keefer (see all)

Tags:

Creative Commons License

This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.