DEV Community

Tarun Garg
Tarun Garg

Posted on

Getting Started with Service Workers

What is a Service Worker?

A Service Worker can be thought of as a JavaScript file that runs in the context of Shared Worker . It acts as a proxy between the browser and network.

Service Workers help developers build offline first web applications, what we meant by offline first is even if there is no/slow internet connection on user's device in those scenarios also, we will be able to serve our app, or something, to user which is at par or better than native apps that work offline.

Service Workers run in a different thread than normal JavaScript thread, hence all communication between the Service Worker thread and main thread happen through messages sent via postMessage. One of the less talked features is that it is completely asynchronous and all synchronous web APIs won't go well with it.

Service Workers sits between our browser and network, so in order to prevent our web app from middle man attacks, Service Workers run only on HTTPS [ localhost is an exception ].

Service Workers make heavy use of modern web constructs, listed below :

It is recommended that you get familiar with above APIs before going further.

The Service Worker API is still a new feature in web, so is supported (or partially supported) by few browsers, you can check compatibility here.

Registration

In order to be able to use Service Workers in our app, we have to first register it. Registering a Service Worker is nice and easy.

First, we'll detect if our browser supports Service Worker or not.

// check if Service Worker support exists in browser or not
if( 'serviceWorker' in navigator ) {
    //Service Worker support exists
} else {
    //still not supported
}

Now that we have detected if Service Worker support exists or not in the browser, it's time to register it.

The below code registers a Service Worker present in service-worker.js file which has the scope of the whole of our application.

// check if Sevice Worker support exist in browser or not
if( 'serviceWorker' in navigator ) {
    navigator.serviceWorker  
                       .register( 'service-worker.js' , { scope : ' ' } )  
                       .then( function( ) { 
                           console.log('Congratulations!!Service Worker Registered');
                       })
                       .catch( function( err) {
                           console.log(`Aagh! Some kind of Error :- ${err}`);
                       });
} else {
    //still not supported
}

In the above code snippet

  • We are detecting if Service Worker support exists in our browser or not.
  • If support exists, we are registering a file named service-worker.js(You can name your file anything). Note that, ideally service-worker.js file should be in the root directory. If our Service Worker file is present in some other directory like directory/service-worker.js then Service Worker will get in action only when something from directory/ is being fetched.
  • We are passing an object as the second argument to register() function which has a scope key whose value is ' ', that means we want to register Service worker in the scope of the current directory. You can also manually set Service Worker scope to any directory of your choice.
  • Finally, since whole Service worker is 'promise based', we are catching if Service Worker is registered or not.

If you don't pass anything as the second argument to register() function then Service Worker is registered against the directory in which Service Worker file is present.

Now registration of Service Worker is complete.

Let's dig in a bit deeper. As we discussed earlier that Service Worker communicate with main thread using messages/events. Whenever we do a network fetch, the network request goes through the Service Worker and triggers a fetch event.

Now in our service-worker.js let's add a listener for this event.

self.addEventListener( "fetch" , function (event) {
    //fetch request as specified by event object 
    console.log(event.request); //Note that Request and Response are also objects 
});

Above code does nothing fancy. It simply logs out Request object to browser console.

Once we have the request object, we could intercept this request the way we wanted it to be. We'll do all this heroics later.

Seems easy till now? Ok, let's move to hardest, but most important, part.

The Service Worker LifeCycle

If we try to run above example in our browser and look at the console, we see below message

Congratulations!! Service Worker Registered

But this message is for first time load only and when we refresh our page again then we get all request objects printed in console.

This behavior is due to the fact that when we first loaded the page there was no Service Worker registered so the listener for fetch event can't be triggered, so at the first, we could only see Service Worker being registered.

Now when we refreshed the page, a new web client is spawned and it saw that there is already a Service Worker registered against this domain so fetch event listener comes to action and we could see request objects printed in the browser console.

Now if you try to change something in your fetch event listener, let's suppose we change to this:-

self.addEventListener( "fetch" , function (event) {
    //fetch request from event object 
    console.log("Resource requested is :- ",event.request.url);
});

And now if we refresh the page we could not see any change in the console. Seems weird???

The main culprit for this kind of sin is the Service Worker lifecycle.

A Service Worker has a life cycle which consists of following stages:

  • Install
  • Activate
  • Wait
  • Terminated/Redundant

Let's analyze above scenario where we changed something and it was not reflected when we refreshed the page.

In this case, we already had an old Service Worker registered for the page in active state and when we refreshed the page, browser saw that the Service Worker file has been updated. It installs new Service worker but keeps this new one in waiting state because old one has not been terminated. The old one can only be terminated by closing all pages that reference it or by navigating to other domain over which this Service Worker has no control. If we terminate our Service Worker by the above-mentioned methods and again head over to our page we should see the changes in effect because now no active page was referencing an old Service Worker so new ones becomes active and old one becomes redundant. As soon as the new Service Worker takes control, it will fire an activate event.

Now, how, as a developer, do we know which state is our Service Worker in and how can we manually alter lifecycle to make our development easy.

Here comes Chrome Dev Tools to rescue.

In chrome dev tools [Can be opened by pressing F12 or CTRL+SHIFT+I or Ctrl+Shift+J]. Head over to Application panel and we can see a separate resource written as Service Workers.

Here, we can see in which state we are in. We can also manually unregister, update & stop any Service Worker and we can also see when one was received.

There is more to it.

Suppose we as a developer want that, every time we reload/change something in our page new Service Worker should be active without going to waiting state, we can do that too. But remember it only effects on developer's site, for this to become active on consumer site too we'll cover that in future ;).

For doing above mentioned, just check checkbox corresponding to Update on Reload.

You can also do more things like going offline for testing, bypassing network, using push and sync for Service Worker. All in all, there's great tooling support available for Service Workers now and it is fairly stable.


Let's Code

We now have a basic understanding of all know hows of Service Worker. Let's go and see how can we use them in our applications.

Before getting our hands dirty in code, we'll recollect what we have learned so far.

  • The Service Worker acts as a proxy between our browser and network.
  • These are essentially worker threads that run in a different context and has scope defined.
  • They run on HTTPS only exception is localhost.
  • A new Service Worker is kept on waiting state till all pages that reference old Service-worker have not been closed.

Now, for the sake of programming something with the Service Worker API, we'll set up a project with a folder structure like below:

- [styles/](https://github.com/tarungarg546/service-worker-intro/tree/master/styles)
- [fonts/](https://github.com/tarungarg546/service-worker-intro/tree/master/fonts)
- [scripts/](https://github.com/tarungarg546/service-worker-intro/tree/master/scripts)
- [index.html](https://github.com/tarungarg546/service-worker-intro/blob/master/index.html)
- [service-worker.js](https://github.com/tarungarg546/service-worker-intro/blob/master/service-worker.js)

Where styles/ contain usual stylesheets, fonts/ contains usual fonts, scripts/contains usual JavaScript files, service-worker.js is the file where we'll deal with Service Worker.

For styling purposes, we'll use bootstrap here because in this tutorial our main concern is using and deploying Service Worker, not styles.

Copy the following in index.html :

<!DOCTYPE html>
<html>
<head>
    <!--Set Meta tags-->
    <meta charset="UTF-8">
    <meta name="description" content="Service Worker introductory tutorial">
    <meta name="author" content="Tarun Garg">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Service Workers intro</title>
    <!-- Compiled and minified CSS -->
    <link rel="stylesheet" href="styles/bootstrap.min.css">
    <script type="text/javascript">
        //detect if browser supports Service Worker
        if('serviceWorker' in navigator) {  
            //browser supports now register it
            navigator.serviceWorker  
                   .register('service-worker.js') //Note that we have not passed scope object here now by default it will pick root scope 
                   .then(function() { 
                        console.log('Service Worker Registered'); 
                    })
                    .catch(function(err) {
                        console.error(err);
                    })
        } else {
            console.log("Ahh! Your browser does not supports serviceWorker");
        }
    </script>
</head>
<body>
    <div class="container">
        Hello world!
    </div>
</body>
</html>

In the above snippet, we are registering a file named service-worker.js which has all Service Worker logic.

Now when we serve this for the first time, we will see Service Worker Registered message in our browser console.

Now in our service-worker.js.
Let's add code for fetch and install events.

var log = console.log.bind(console);//bind our console to a variable
//Add event listener for install
self.addEventListener("install", function(event) {
    log('[ServiceWorker] Installed!');
});
//Add event listener for fetch
self.addEventListener("fetch", function(event) {
    log('[ServiceWorker] Requested url :-', event.request.url);
});

If we refresh our browser and head over to Application panel in our Chrome dev tools, we will see one the Service Worker in an active state and other is in waiting state.

We can now understand, why it's in a waiting state. To put it in a current state, either close and reopen this tab or go to any page that is not controlled by this Service Worker and come back. You will see correct console messages and Service Worker being active.

Until now, we have worked with the Service worker API in seldom now it's time to integrate it other API such as Cache API.

Since, Cache API is key-value store for Request and Response objects and have access to window scope as well as Service-worker scope.

Since next step in the Service worker lifecycle after registering is installing . So we'll cache all the resource in this event handler of Service Worker. And when we fetch we'll see if the requested resources are present in the cache or not, if they are present then return those cached files else fetch from the network.

Edit your service-worker.js with this :-

var log = console.log.bind(console);//bind our console to a variable
var version = "0.0.1";
var cacheName = "sw-demo";
var cache = cacheName + "-" + version;
var filesToCache = [
                    'scripts/bootstrap.min.js',
                    'styles/bootstrap.min.css',
                    'index.html',
                    "/",//Note that this is different from below 
                    "/?app=true"//This is different from above in request object's terminology
                 ];

//Add event listener for install
self.addEventListener("install", function(event) {
    log('[ServiceWorker] Installing....');

    //service-worker will be in installing state till event.waitUntil completes
    event.waitUntil(caches
                        .open(cache)//open this cache from caches and it will return a Promise
                        .then(function(cache) { //catch that promise
                            log('[ServiceWorker] Caching files');
                            cache.addAll(filesToCache);//add all required files to cache it also returns a Promise
                        })
                    ); 
});

//Add event listener for fetch
self.addEventListener("fetch", function(event) {
    //note that event.request.URL gives URL of the request so you could also intercept request and send response based on your URL
    //e.g. you make want to send gif if anything in jpeg form is requested.
    event.respondWith(
    //it either expects a Response object as a parameter or a promise that resolves to a Response object
                        caches.match(event.request)//If there is a match in the cache of this request object
                            .then(function(response) {
                                if(response) {
                                    log("Fulfilling "+event.request.url+" from cache.");
                                    //returning response object
                                    return response;
                                } else {
                                    log(event.request.url+" not found in cache fetching from network.");
                                    //return promise that resolves to Response object
                                    return fetch(event.request);
                                }
                            })
                    );
});

In above code install and fetch looks easy. The important thing is fileToCache Array and cache which is just a variable which is a combination of version and cacheName.

We are maintaining version number because when we update any file present in the fileToCache array, Service Worker does not automatically pick up this change, the only change it picks up is a change in service-worker.js file itself. So, in those cases it's easy to just change the version then Service Worker would see that there are changes in its own code so it will install new Service Worker and in the process of installing new Service Worker it will cache new resource and put that in the new cache with separate version. You can check out the cache as below in chrome dev tools.

Now if we change our version, our Service Worker will pick up that change and the new Service Worker will go through the same cycle of registering, installing and then waiting, since the old Service Worker has not been terminated yet. And since new Service Worker is installed, the new cache will be created without affecting old version. That is the purpose of versioning. With a single keystroke, you can do all these things and simultaneously not breaking old caches.

When the future new Service Worker becomes active, what we ideally want is to delete old cache because it'll be no longer wanted. For this purpose, we will utilize activate event.

Update your service-worker.js with this :-

var log = console.log.bind(console);//bind our console to a variable
var version = "0.0.2";
var cacheName = "sw-demo";
var cache = cacheName + "-" + version;
var filesToCache = [
                    'scripts/bootstrap.min.js',
                    'styles/bootstrap.min.css',
                    'index.html',
                    "/",//Note that this is different from below 
                    "/?app=true"//This is different from above in request object's terminology
                 ];

//Add event listener for install
self.addEventListener("install", function(event) {
    log('[ServiceWorker] Installing....');
    event.waitUntil(caches
                        .open(cache)//open this cache from caches and it will return a Promise
                        .then(function(cache) { //catch that promise
                            log('[ServiceWorker] Caching files');
                            cache.addAll(filesToCache);//add all required files to cache it also returns a Promise
                        })
                    ); 
});

//Add event listener for fetch
self.addEventListener("fetch", function(event) {
    //note that event.request.url gives URL of the request so you could also intercept the request and send a response based on your URL
    //e.g. you make want to send gif if anything in jpeg form is requested.
    event.respondWith(//it either takes a Response object as a parameter or a promise that resolves to a Response object
                        caches.match(event.request)//If there is a match in the cache of this request object
                            .then(function(response) {
                                if(response) {
                                    log("Fulfilling "+event.request.url+" from cache.");
                                    //returning response object
                                    return response;
                                } else {
                                    log(event.request.url+" not found in cache fetching from network.");
                                    //return promise that resolves to Response object
                                    return fetch(event.request);
                                }
                            })
                    );
});

self.addEventListener('activate', function(event) {
  log('[ServiceWorker] Activate');
  event.waitUntil(
                    caches.keys()//it will return all the keys in the cache as an array
                    .then(function(keyList) {
                            //run everything in parallel using Promise.all()
                            Promise.all(keyList.map(function(key) {
                                    if (key !== cacheName) {
                                        log('[ServiceWorker] Removing old cache ', key);
                                        //if key doesn`t matches with present key
                                        return caches.delete(key);
                                    }
                                })
                            );
                        })
                );
});

In the above code, we are getting all keys from the cache and then checking if a key does not equal to current cache name then delete it.

Now if you try to go offline and reload the page you'll still see your web app working as if you are online because when you do a network request it is being intercepted and fulfilled from the cache. This way, you can give your user a soothing experience of your web app when there is no internet OR slow internet on user's site.

This is just a small tutorial to get you started with Service Workers. There is a lot more to it but for "getting started", this should work as a tonic.

Hope you enjoyed this post :).


Resources

Top comments (5)

Collapse
 
mporam profile image
Mike Oram

This is brilliant! I have read 15+ articles about PWAs and looked through at least 5 PWA tutorials, this is the first one is not really high level, that explains it step by step and does not explain simple concepts unnecessarily. Thank you!

Collapse
 
tarungarg546 profile image
Tarun Garg

Thanks means a lot <3

Collapse
 
tilp profile image
TilP

Very good article. Just wondering, the line in activate where you compare key to cache name, should we be comparing with version instead? I won't know until I run this code. Thanks!

Collapse
 
reeder29 profile image
Doug Reeder

Testing service workers is not easy. One good way is to mock the environment using Node.js, using Zack Argyle's service-worker-mock: hackernoon.com/service-worker-test...

Collapse
 
alvinmilton profile image
Alvin Milton

+100