A service worker is a script that your browser runs in the background, separate from a web page, opening the door to features that don't need a web page or user interaction. Today, they already include features like push notifications and background sync. In the future, service workers might support other things like periodic sync or geofencing. The core feature discussed in this code lab is the ability to intercept and handle network requests, including programmatically managing a cache of responses.
The reason this is such an exciting API is that it allows you to support offline experiences, giving developers complete control over the experience.
In this code lab, we are building on top of the project started in Understanding the App Manifest
code lab.
If you didn't do it already: Fork and then Clone the following repository: https://github.com/The-Guide/fe-guild-2019-pwa.git
$ git clone https://github.com/[YOUR GITHUB PROFILE]/fe-guild-2019-pwa.git
$ cd fe-guild-2019-pwa
If you want to start directly with Service Workers
checkout the following branch:
$ git checkout pwa-service-workers-init
First install the dependencies
$ npm install
Then type in the terminal
$ npm start
and open Chrome at localhost:8080
A service worker has a lifecycle that is completely separate from your web page.
With service workers, the following steps are generally observed for basic set up:
register()
is successful. i.e., a document starts life with or without a Service worker and maintains that for its lifetime. So documents will have to be reloaded to actually be controlled.To install a service worker, you need to kick start the process by registering it on your page. This tells the browser where your service worker JavaScript file lives.
Create a file named sw.js
on the same level as index.html
then in src/js/app.js
:
window.addEventListener('load', () => {
...
// After the existing code
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register(`${baseUrl}sw.js`)
.then( registration => {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(err => {
// registration failed :(
console.log('ServiceWorker registration failed: ', err);
});
}
});
This code checks to see if the service worker API is available, and if it is, the service worker at [baseUrl]/sw.js
is registered once the page is loaded.
You can call register()
every time a page loads without concern; the browser will figure out if the service worker is already registered or not and handle it accordingly.
One subtlety with the register()
method is the location of the service worker file. In this case, the service worker file is at the root of the domain. This means that the service worker's scope will be the entire origin. In other words, this service worker will receive fetch events for everything on this domain. If we register the service worker file at /src/js/sw.js
, then the service worker would only see fetch events for pages whose URL starts with /src/js/
.
Now you can check that a service worker is enabled by opening the Chrome Developer Tools and then Application -> Service Workers
You may find it useful to test your service worker in an Incognito window so that you can close and reopen knowing that the previous service worker won't affect the new window. Any registrations and caches created from within an Incognito window will be cleared out once that window is closed.
This could be for the following reasons:
localhost
, but to deploy it on a site you'll need to have HTTPS setup on your server.The below graphic shows a summary of the available service worker events:
Event | Description |
install | It is sent when the service worker is being installed. |
activate | It is sent when the service worker has been registered and installed. This place is where you can clean up anything related to the older version of the service worker if it’s been updated. |
message | It is sent when a message is received in a service worker from another context |
fetch | It is sent whenever a page of your site requires a network resource. It can be a new page, a JSON API, an image, a CSS file, whatever. |
sync | It is sent if the browser previously detected that the connection was unavailable, and now signals the service worker that the internet connection is working. |
push | It is invoked by the Push API when a new push event is received. |
Let's listen to the main lifecycle events install
and activate
In sw.js
self.addEventListener('install', event => {
console.log('[Service Worker] Installing Service Worker ...', event);
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
console.log('[Service Worker] Activating Service Worker ...', event);
return self.clients.claim();
});
In the install
, callback is calling the skipWaiting()
function to trigger the activate
event and tell the Service Worker to start working immediately without waiting for the user to navigate or reload the page.
The skipWaiting()
function forces the waiting Service Worker to become the active Service Worker. The self.skipWaiting()
function can also be used with the self .clients.claim()
function to ensure that updates to the underlying Service Worker take effect immediately.
The code in the next listing can be combined with the skipWaiting()
function in order to ensure that your Service Worker activates itself immediately.
Add to Home Screen, sometimes referred to as the web app install prompt, makes it easy for users to install your Progressive Web App on their mobile or desktop device. After the user accepts the prompt, your PWA will be added to their launcher, and it will run like any other installed app.
Chrome handles most of the heavy lifting for you:
In order for a user to be able to install your Progressive Web App, it needs to meet the following criteria:
short_name
or name
icons
must include a 192px and a 512px sized iconsstart_url
display
must be one of: fullscreen
, standalone
, or minimal-ui
fetch
event handlerWhen these criteria are met, Chrome will fire a beforeinstallprompt
event that you can use to prompt the user to install your Progressive Web App.
We meet all the criteria above but one: we don't have a fetch
event handler. Let's fix that
In sw.js
self.addEventListener('fetch', event => {
console.log('[Service Worker] Fetching something ....', event);
// This fixes a weird bug in Chrome when you open the Developer Tools
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
event.respondWith(fetch(event.request));
});
In Chrome click the Customize button and select Install Progressive Selfies...
The easiest way is to have the app served over HTTPS
is to publish it to GitHub Pages
but in order to do that there are several changes needed:
Hosting our site to GitHub Pages
will place it under the following URL: https://[YOUR GITHUB PROFILE].github.io/fe-guild-2019-pwa/
(assuming that you didn't change the repository name when forking). This means that our base
tag needs to change to
<base href="/fe-guild-2019-pwa/">
Changing the base
tag will break our app locally. To fix that rename the public
folder to fe-guild-2019-pwa
. Stop and restart the app, and we can access it now on localhost:8080/fe-guild-2019-pwa/
To easily deploy to GitHub Pages
install the gh-pages
node package:
$ npm install gh-pages --save-dev
Add a new script to package.json
"deploy": "gh-pages -d fe-guild-2019-pwa"
and run$ npm run deploy
The first deployment will take longer because it will create a new branch gh-pages
and will activate GitHub Pages
on this new branch.
https://[YOUR GITHUB PROFILE].github.io/fe-guild-2019-pwa/
You can now access the application from your phone, and if you have an Android phone, you will also get an App Install Banner
(Try to group around a colleague that has one to see it in action).If for some reason you’d prefer not to show the Add to Home Screen banner, you can cancel it completely, as shown in the next listing. Depending on which type of web app you have, it may not make sense to show this prompt. Perhaps your site covers sensitive topics or a short-lived event for which the banner might be more annoying to the user than helpful.
In src/js/app.js
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
return false;
});
The code in the listing above will listen for the beforeinstallprompt
event and prevent the default behavior of the banner if it’s fired. The code is straightforward and uses the standard JavaScript preventDefault()
function to cancel the event and returns a false value—both of which are needed to ensure that the banner doesn't appear.
The Add to Home Screen functionality can be helpful for your users, but it’s important to find out if it would be or not. Are your users annoyed by the banner and dismiss it when it appears? Do they trust your application enough to add it to their device?
By listening for the beforeinstallprompt
event, you can determine whether a user decided to add your web app to their home screen or if they dismissed it. The following listing shows how.
In src/js/app.js
window.addEventListener('beforeinstallprompt', event => {
// Determine the user's choice - returned as a Promise
event.userChoice.then(result => {
console.log(result.outcome);
// Based on the user's choice, decide how to proceed
if(result.outcome == 'dismissed') {
// Send to analytics
} else {
// Send to analytics
}
});
});
At this point, you could decide to send this information to your Web Analytics tools to track the usage of this functionality over time. This technique can be a useful approach for understanding how your users interact with your Add to Home Screen prompt.
Using a combination of the code in listings above, you can defer the Add to Home Screen banner
to appear until a later time—for example, if a user visits a site and has met the criteria for the banner to be shown, but you’d prefer them to add your site by allowing them to tap a custom button on your site instead. This puts the user in control of whether or not they’d like to add your site, instead of the browser choosing when it should show the banner.
In src/js/app.js
let deferredPrompt;
window.addEventListener('beforeinstallprompt', event => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
event.preventDefault();
console.log('beforeinstallprompt fired');
// Stash the event so it can be triggered later.
deferredPrompt = event;
return false;
});
In src/js/feed.js
change the openCreatePostModal
function
const openCreatePostModal = () => {
createPostArea.style.transform = 'translateY(0)';
if(deferredPrompt) {
deferredPrompt.prompt();
// Determine the user's choice - returned as a Promise
deferredPrompt.userChoice.then(result => {
console.log(result.outcome);
// Based on the user's choice, decide how to proceed
if (result.outcome === 'dismissed') {
// Send to analytics
console.log('User cancelled installation');
} else {
// Send to analytics
console.log('User added to home screen');
}
});
deferredPrompt = null;
}
};
Clicking the FAB
button in the lower right corner will produce
Imagine you’re on a train using your mobile phone to browse your favorite website. Every time the train enters an area with an unreliable network, the website takes ages to load—an all-too-familiar scene. This is where Service Worker caching comes to the rescue. Caching ensures that your website loads as efficiently as possible for repeat visitors.
Modern browsers can interpret and understand a variety of HTTP requests and responses and are capable of storing and caching data until it’s needed. After the data has expired, it will go and fetch the updated version. This ensures that web pages load faster and use less bandwidth.
A web server can take advantage of the browser’s ability to cache data and use it to improve the repeat request load time. If the user visits the same page twice within one session, there’s often no need to serve them a fresh version of the resources if the data hasn't changed. This way, a web server can use the Expires header to notify the web client that it can use the current copy of a resource until the specified “Expiry date.” In turn, the browser can cache this resource and only check again for a new version when it reaches the expiry date.
HTTP caching is a fantastic way to improve the performance of your website, but it isn’t without flaws. Using HTTP caching means that you’re relying on the server to tell you when to cache a resource and when it expires. If you have content that has dependencies, any updates can cause the expiry dates sent by the server to easily become out of sync and affect your site.
You may be wondering why you even need Service Worker caching if you have HTTP caching. How is Service Worker caching different? Well, instead of the server telling the browser how long to cache a resource, you are in complete control. Service Worker caching is extremely powerful because it gives you programmatic control over exactly how you cache your resources. As with all Progressive Web App (PWA) features, Service Worker caching is an enhancement to HTTP caching and works hand-in-hand with it.
The power of Service Workers lies in their ability to intercept HTTP requests. In this step, we’ll use this ability to intercept HTTP requests and responses to provide users with a lightning-fast response directly from cache.
Using Service Workers, you can tap into any incoming HTTP requests and decide exactly how you want to respond. In your Service Worker, you can write logic to decide what resources you’d like to cache, what conditions need to be met, and how long to cache a resource for.
When the user visits the website for the first time, the Service Worker begins downloading and installing itself. During the installation stage, you can tap into this event and prime the cache with all the critical assets for the web app.
In sw.js
const CACHE_STATIC_NAME = 'static';
const URLS_TO_PRECACHE = [
'/',
'index.html',
'src/js/app.js',
'src/js/feed.js',
'src/lib/material.min.js',
'src/lib/material.indigo-deep_orange.min.css',
'src/css/app.css',
'src/css/feed.css',
'src/images/main-image.jpg',
'https://fonts.googleapis.com/css?family=Roboto:400,700',
'https://fonts.googleapis.com/icon?family=Material+Icons',
// 'https://code.getmdl.io/1.3.0/material.indigo-deep_orange.min.css"'
];
self.addEventListener('install', event => {
console.log('[Service Worker] Installing Service Worker ...', event);
event.waitUntil(
caches.open(CACHE_STATIC_NAME)
.then(cache => {
console.log('[Service Worker] Precaching App Shell');
cache.addAll(URLS_TO_PRECACHE);
})
.then(() => {
console.log('[ServiceWorker] Skip waiting on install');
return self.skipWaiting();
})
);
});
The code above taps into the install event and adds a list of files URLS_TO_PRECACHE
during this stage. It also references a variable called CACHE_STATIC_NAME
. This is a string value that I've set to name the cache. You can name each cache differently, and you can even have multiple different copies of the cache because each new string makes it unique. This will come in handy later in the step when we look at versioning and cache busting.
You can see that once the cache has been opened, you can then begin to add resources into it. Next, you call cache.addAll()
and pass in your array of files. The event.waitUntil()
method uses a JavaScript promise to know how long installation takes and whether it succeeded.
Important to return the promise here to have skipWaiting()
fire after the cache has been updated.
If all the files are successfully cached, the Service Worker will be installed. If any of the files fails to download, the install
step will fail. This is important because it means you need to rely on all the assets being present on the server and you need to be careful with the list of files that you decide to cache in the install step. Defining a long list of files will increase the chances that one file may fail to cache, leading to your Service Worker not being installed.
If you check now in Chrome Developer Tools you will see that the cache is filled with the static files from the URLS_TO_PRECACHE
array:
But if you look in the Network
tab (even after a refresh) the files are still fetched over the network:
The reason is that the cache is primed and ready to go, but we are not reading assets from it. In order to do that we need to add the code in the next listing to our Service Worker in order to start listening to the fetch event.
self.addEventListener('fetch', event => {
console.log('[Service Worker] Fetching something ....', event);
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
console.log(response);
return response;
}
return fetch(event.request);
})
);
});
We are checking if the incoming URL matches anything that might exist in our current cache using the caches.match()
function. If it does, return that cached resource, but if the resource doesn't exist in the cache, continue as normal and fetch the requested resource.
After the Service Worker installs and activates refresh the page and check the Network
tab again. The Service Worker will now intercept the HTTP request and load the appropriate resources instantly from the cache instead of making a network request to the server.
At this moment if we set Offline
mode in the Network
tab our app will look like this:
So far we cached important resources during the installation of a Service Worker, which is known as precaching. This works well when you know exactly the resources that you want to cache, but what about resources that might be dynamic or that you might not know about? For example, our website might be a sports news website that needs constant updating during a match; we won’t know about those files during Service Worker installation.
Because Service Workers can intercept HTTP requests, this is the perfect opportunity to make the HTTP request and then store the response in the cache. This means that we will request the resource and then cache it immediately. That way, as the next HTTP request is made for the same resource, we can instantly fetch it out of the Service Worker cache.
In sw.js
// Add a new cache for dynamic content
const CACHE_DYNAMIC_NAME = 'dynamic';
self.addEventListener('fetch', event => {
console.log('[Service Worker] Fetching something ....', event);
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
console.log(response);
return response;
}
// Clone the request - a request is a stream and can be only consumed once
const requestToCache = event.request.clone();
// Try to make the original HTTP request as intended
return fetch(requestToCache)
.then(response => {
// If request fails or server responds with an error code, return that error immediately
if (!response || response.status !== 200) {
return response;
}
// Again clone the response because you need to add it into the cache and because it's used
// for the final return response
const responseToCache = response.clone();
caches.open(CACHE_DYNAMIC_NAME)
.then(cache => {
cache.put(requestToCache, responseToCache);
});
return response;
})
})
.catch(error => console.log('[Service Worker] Dynamic cache error.', error))
);
});
The code above caches the resource fetched from the network and returns it back to the page. If we reload the page, the resources cached in both caches will be matched.
There will be a point in time where the Service Worker cache will need updating. If we make changes to the web application when need to be sure users receive the newer version of files instead of older versions. As you can imagine, serving older files by mistake would cause havoc on a site.
The great thing about Service Workers is that each time we make any changes to the Service Worker file itself, it automatically triggers the Service Worker update flow. In step 3, we looked at the Service Worker lifecycle. Remember that when a user navigates to the site, the browser tries to re-download the Service Worker in the background. If there’s even a byte’s difference in the Service Worker file compared to what it currently has, it considers it new.
This useful functionality gives you the perfect opportunity to update your cache with new files. The best things to change are the cache
names by adding a version to them. But if we update cache name, this would automatically create a new cache and start serving your files from that cache. The original cache would be orphaned and no longer used, and we need to delete it. The best place to delete after the old Service Worker is the activate
event.
In sw.js
first add _v1
to both cache names:
const CACHE_STATIC_NAME = 'static_v1';
const CACHE_DYNAMIC_NAME = 'dynamic_v1';
Then adapt the activate
callback:
self.addEventListener('activate', event => {
console.log('[Service Worker] Activating Service Worker ...', event);
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(cacheNames.map(cacheName => {
if (cacheName !== CACHE_STATIC_NAME && cacheName !== CACHE_DYNAMIC_NAME) {
console.log('[Service Worker] Removing old cache.', cacheName);
return caches.delete(cacheName);
}
}));
})
.then(() => {
console.log('[ServiceWorker] Claiming clients');
return self.clients.claim();
})
);
});
Offline page
Even if we cache the help
page when we navigate to it, we may happen to become offline before it gets cached, and in this case, we get the browser's default offline page. To account for this case, we can create our own offline.html
page:
index.html
file and name the duplicate offline.html
main
tag with
<main class="mdl-layout__content mat-typography">
<div class="page-content">
<h5 class="text-center mdl-color-text--primary">We're sorry, this page hasn't been cached yet :/</h5>
<p>But why don't you try one of our <a href="/fe-guild-2019-pwa/">other pages</a>?</p>
</div>
</main>
offline.html
to the list of files to be precached.static
cache.fetch
handler replace the.catch(error => console.log('[Service Worker] Dynamic cache error.', error)));
with
.catch(error => {
return caches.open(CACHE_STATIC_NAME)
.then(cache => {
if (event.request.headers.get('accept').includes('text/html')) {
return cache.match('/fe-guild-2019-pwa/offline.html');
}
});
})
help
page entry in the dynamic cache if you have it.offline
page being displayed.HTTP caching is a fantastic way to improve the performance of your website, but it isn’t without flaws.
Service Worker caching is extremely powerful because it gives you programmatic control over exactly how you cache your resources. When used hand-in-hand with HTTP caching, you get the best of both worlds.
Used correctly, Service Worker caching is a massive performance enhancement and bandwidth saver.
You can use a number of different approaches to cache resources, and each of them can be adapted to suit the needs of your users.
We are going to simplify Service Worker Management with Workbox.
Here is a checklist which breaks down the things we learned in this code lab.
lifecycle
and most important events
$ git checkout pwa-service-workers-final