Service worker to get offline mode

Introduction

The service workers act as background proxies. When a network request is made through our website, they first go through the service worker that is in use. Thanks to this and other APIs such as caching, we can intercept a network request and immediately return a cached version, obtaining availability against network failures (offline mode), as well as speed by not having to carry the request to the server.

The problem with this strategy known as offline-first is that the saved version is always displayed even if it is not the most current. This problem can be solved in many ways, for example:

  • Display the saved version and perform in the background a new network request to cache it. After this the user is informed that there is new data to refresh the page and see the new version.

  • Using cache invalidation techniques to remove the saved version of pages that are out of date.

  • By creating a new cache on each deployment. This is known as versioning: cache-v1, cache-v2, etc. Each new deployment invalidates and removes the old caches.

These solutions add a lot of complexity, especially if they are done from scratch without using a library like Workbox.

For these reasons I have changed the strategy of this blog from offline-first to network-first with offline mode. It's not perfect but it's a fairly simple way to offer offline mode to already visited pages, while avoiding dealing with pages that become obsolete.

Here's a curiosity that I admit took me a while to figure out. Can you tell why this doesn't work?

  • JavaScript
event.waitUntil(async () => {
  // ...
})

Functions like event.waitUntil() or event.respondWith() do not accept a function as an argument, but a promise. So that you have to call the asynchronous function (for example, via an IIFE) so that it returns a promise and is passed as an argument.

  • JavaScript
event.waitUntil((async () => {
  // ...
})())

Service worker - install

The sw.js file starts with the install event. This is executed when the service worker is first installed in the user's browser.

sw.js
  • JavaScript
self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    // We open the cache called "cache" (how original).
    const cache = await caches.open('cache')

    /**
     * We make network requests to three elements to cache them
     * so that they are always available.
     */
    await cache.addAll([
      // The page that offline users will see.
      '/offline',

      // The main CSS file.
      '/themes/default.css',

      // The font used almost everywhere.
      '/fonts/Nunito.woff2',
    ])

    self.skipWaiting()
  })())
})

Service worker - activate

Then we activate the service worker:

sw.js
  • JavaScript
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim())
})

Service worker - fetch

And finally the fetch event is in charge of intercepting the network requests and managing the entire strategy. Let's see step by step through the comments.

sw.js
  • JavaScript
self.addEventListener('fetch', (event) => {
  /**
   * In the case of non-GET requests, requests to
   * other domains, or requests to pages such as "rss.xml" or
   * "sitemap.xml", we don't want to cache the result or serve
   * cached versions, so we immediately respond
   * with a network request. If there were no network... bad luck!
   */
  if (
    event.request.method !== 'GET'
    || !event.request.url.includes('felixsanz.dev')
    || event.request.url.endsWith('.xml')
  ) {
    event.respondWith(fetch(event.request))
    return
  }

  // Otherwise we respond with the result of the async function.
  event.respondWith((async () => {
    // First we open the cache in this event.
    const cache = await caches.open('cache')

    // We check if the element already exists in the cache.
    const cached = await caches.match(event.request, { ignoreSearch: true })

    /**
     * We create a controller to be able to cancel the network request
     * when it takes too long and thus proceed to show the offline mode.
     */
    const controller = new AbortController()

    /**
     * We create a timer so that the network request is canceled
     * after a certain time.
     *
     * If the element is cached, we will wait 6 seconds.
     * Otherwise, we will give some more margin and wait 12 seconds.
     */
    const timeout = setTimeout(() => controller.abort(), (cached) ? 6000 : 12000)

    try {
      // We perform the network request.
      const response = await fetch(event.request, { signal: controller.signal })

      // The request was valid, we cancel the timer.
      clearTimeout(timeout)

      /**
       * If the response is successful, we clone and cache it.
       *
       * The next time the offline user accesses this element they will be able to
       * view it.
       */
      if (response.status === 200) {
        await cache.put(event.request, response.clone())
      }

      /**
       * We respond with the result of the fetch whether it is 200 or any
       * other result (30x, 40x...).
       */
      return response
    } catch (error) {
      /**
       * If the network request was invalid we will end up here.
       *
       * This may be, for example, because there has been an error on
       * the server (50x), or simply because the timer passed
       * without getting a network response, and the controller aborted
       * the network request so as not to wait any longer.
       *
       * If the error comes from the server, we still have the timer active
       * so we must cancel it.
       */
      if (error.name !== 'AbortError') {
        clearTimeout(timeout)
      }

      // Display the cached version if it exists, or the /offline page.
      return cached || cache.match('/offline')
    }
  })())
})

If you want to access the version of the service worker without comments so you can copy and paste, remember that you have it in /sw.js or in the blog repository on GitHub, inside snippets/service-worker-to-get-offline-mode/sw.js.

You can support me so that I can dedicate even more time to writing articles and have resources to create new projects. Thank you!