< Back
calendar

Feb 9, 2020

How To Make Your Website Work Offline

Better performance and user experience in one easy step

Header for posting How To Make Your Website Work Offline
Photo by Jonathan Kemper on Unsplash

This is part 1 of a two-part series, part 2

Did you ever open a native app on your phone to be greeted with the picture of a dinosaur, telling you that you need to connect to the internet before you can even use the app?

No. Most native apps give you a much better user experience and show you at least something until you’re connected again.

But if you disconnect from the internet and visit a random website then that’s usually what you get: nothing.

Offline image of dinosaur in Chrome
The dinosaur is there for a reason

And that’s a shame, because there’s no reason to provide such a poor experience and many reasons to provide a better one. If users can at least use your website with limited functionality until they’re online again, you’ve provided a much better user experience.

But being completely offline doesn’t really happen that much to users. What happens much more often, is that users are on slow and flaky connections. When that happens, your website will take forever to load and users just won’t bother anymore and leave.

74% of people will leave a mobile website which requires over 5 seconds to load.

But what if everything you need to at least show the bare minimum on the screen is already there and you only need the internet to fetch fresh data?

That’s how native apps work. The UI loads immediately and fresh data is fetched from the internet. If the user is offline, stale data can be shown until the user is back online.

If you have made sure that your site’s assets (CSS, images, JavaScript) can be served from a local cache then these will be immediately available and don’t need to be fetched through the network. And if you have made sure that your most frequently visited pages are also locally cached then these will be immediately available as well.

This ensures that your site will provide a better user experience and better performance.

This is how all websites should work and luckily, you can do this today.

How to make your website work offline

Step 1: add a Service Worker
Step 2: enjoy!

A service worker is a Web Worker which is like a proxy server between your website, the browser and the network. It enables you to intercept all requests and responses happening on your website.

Just let that sink in for a minute: by adding a service worker to your website, you now have to power to intercept any outgoing requests and incoming responses. That alone should be enough reason to add one to your website today.

This literally means you can intercept any request and serve basically whatever you want. You can serve static assets straight from the local cache or even serve API responses and BLOBs from IndexedDB.

Service Workers are supported by all modern browsers and work by progressive enhancement, which means that nothing will break when a user visits your website with an old browser that doesn’t support Service Workers. It just won’t work offline in that case.

Service Worker browser support from caniuse.com
Service Worker browser support from caniuse.com

To add a service worker to your website, just create a file named service-worker.js (any name will do) and place it in the root of your app. We then call navigator.serviceWorker.register to actually register the service worker.

Wrap it in a check to make sure old browsers don’t break:

if('serviceWorker' in navigator) {
let registration;

const registerServiceWorker = async () => {
registration = await navigator.serviceWorker.register('./service-worker.js');
};

registerServiceWorker();
}

Great! Your site is now controlled by a service worker, but since the file is still empty it won’t actually do anything. A service worker is an event-driven Web Worker so we need to add code to respond to these events, starting with the lifecycle events.

The service worker lifecycle

To make sure service workers don’t break anything, they have a strictly defined lifecycle. This makes sure that there is only one service worker controlling a certain part of your website (and therefore only one version of your site exists).

In theory you can have multiple service workers controlling your website but only if they control different scopes. For now it’s enough to know that only one service worker can control a certain scope.

To understand service workers, it’s crucial to understand the service worker lifecycle.

The install event

The first event fired is the install event. It is fired when the service worker is downloaded, parsed and executed successfully. If anything goes wrong during this phase the promise returned from navigator.serviceWorker.register is rejected, the install event will not fire and the service worker is discarded. If there was already a service worker running it will continue to run.

If the service worker was successfully installed, the install event will fire and inside the event handler you should cache your static assets. Caching is done using the CacheStorage object, which lives in window.caches.

This is the part where we cache all needed HTML, CSS, JavaScript, images, fonts etc. to show the bare minimum UI of the website. When the user visits the site again or refreshes the page, everything will be served from the local cache which means it will be served immediately.

No need to fetch anything from the network, it’s already there.

To cache all assets, we first open a cache and then pass an array of paths to assets we want to cache to the addAll method. The open method returns a Promise and we pass this Promise to the waitUntil method of the install event to signal to the browser when installing is complete and if it was successful:

const cacheName = 'my-cache';
const filestoCache = [
'/',
'/about',
'/index.html',
'/about.html',
'/css/styles.css',
'/js/app.js',
'/img/logo.jpg'
]; self.addEventListener('install', e => {
e.waitUntil(
caches.open(cacheName)
.then(cache => cache.addAll(filesToCache))
);
});

Again, if the Promise passed to e.waitUntil rejects it will signal a failure of the installing to the browser and the new service worker will be discarded, leaving the existing one (if present) running.

If you have routes that don’t point to a specific file like /about.html but just /about for example, you may need to cache these as well. This is the case when your website is a single page app and every request is routed to /index.html for example.

You should then cache the route so your app can still work and serve the correct view.

The activate event

When installing the (new) service worker was successfull, the activate event will be fired. The service worker is now ready to control your website, but it won’t control it yet.

Only when you refresh the page after the service worker was activated will it control your website. This is again to assure that nothing is broken.

The window(s) of a website that a service worker controls are called its clients. Inside the event handler for the install event it is possible to take control of uncontrolled clients by calling self.clients.claim().

The service worker will then control the website immediately, although this only works when the service worker is activated for the very first time. It doesn’t work when a new version of the service worker is activated:

self.addEventListener('activate', e => self.clients.claim());

Intercepting requests and responses

Now for the thing you’ve been waiting for: intercepting requests and responses.

Whenever a request is made from the website that the service worker controls a fetch event is fired. The request property of the FetchEvent gives access to the request that was made. Inside the event handler we can serve the static assets we added to the cache earlier in the handler for the install event:

self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request)
.then(response => response ? response : fetch(e.request))
)
});

By calling the respondWith method of the FetchEvent the browser’s default fetch handling is prevented. We call it with a Promise that resolves to a Response which is then served.

The call tocaches.match() checks the cache to see if the asset was cached. If it was, it will be served as a Response from the cache, but if it was not, we fetch it from the network by calling fetch(e.request).

This assures that static assets will always be served from the cache as long as they were cached before. Now whenever a user of your website is on a bad mobile connection or even completely offline, the cached assets will still be served and you are able to give your users a good user experience.

If you’ve made sure to cache all static assets and all possible routes, any user that visits any page of your website at least once can now use your website offline.

Here’s the complete code of the service worker that will take care of all this. Put it in a file in the root of your website and give it a name, for example service-worker.js:

// give your cache a name
const cacheName = 'my-cache';

// put the static assets and routes you want to cache here
const filesToCache = [
  '/',
  '/about',
  '/index.html',
  '/about.html',
  '/css/styles.css',
  '/js/app.js',
  '/img/logo.jpg'
];

// the event handler for the activate event
self.addEventListener('activate', e => self.clients.claim());

// the event handler for the install event 
// typically used to cache assets
self.addEventListener('install', e => {
  e.waitUntil(
    caches.open(cacheName)
    .then(cache => cache.addAll(filesToCache))
  );
});

// the fetch event handler, to intercept requests and serve all 
// static assets from the cache
self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request)
    .then(response => response ? response : fetch(e.request))
  )
});

Then somewhere early in your code, register the service worker:

if('serviceWorker' in navigator) {
let registration;

const registerServiceWorker = async () => {
registration = await navigator.serviceWorker.register('./service-worker.js');
};

registerServiceWorker();
}

Congratulations! Your website now works offline and you have provided your users with a better experience and better performance!

Where to go next

To further enhance the offline capabilities and performance of your website, I will show in a next article how to also cache API calls, so dynamic content can also be available immediately.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.