@frontendbe - 2019/11

˷

Offline* with Service Workers

Let’s 🔪 that Chrome’s 🦖!


* among other things

Mehdi Merah

Not a Service Worker expert 😅

mehdi.cc@meduzen

˷

                           
    2015 Reed                            2016+ Altavia-ACT*

We’ll go through

  • The offline web: from file:// to Service Workers
  • What Service Workers enable now + the future
  • Examples
  • UX considerations
  • Lifecycle of a Service Worker
  • Cache management

Offline before Service Workers

  • file://
  • AppCache: old and deprecated spec
  • Desktop: Electron, Flutter, Quasar, NWJS…
  • Mobile: Cordova, Ionic, PhoneGap, NativeScript

Pushing the web forward

@font-face, HTML5, Responsiveness

Clipboard, Geolocation, Full screen, Gamepad API

Websocket, Camera, Microphone, Web GL, Web VR, Web Assembly, Houdini

Share API, Payment API, Bluetooth API, USB API

OS environment access (viewport size, color theme, accessibility settings…)

It won’t stop!

What is a Service Worker?

Essentially, the network police 🚓.

Requires HTTPS (that’s it!) and JavaScript enabled.

Service Workers now

  • working offline

  • better network perf w/ caching

  • (true) push notifications

  • A2HS (add to home screen) prompt to become an app

🚧 Working Draft spec

A2HS prompt on Android - Installability criteria

                 

Open in (PWA installed) app, Firefox Preview (Android)

Browser support

. Chrome, Android 5 Safari Edge 17 Firefox 44 Others but IE11
Service Worker ✅ ✅ iOS 11.3, macOS 11.1 ✅ ✅ ✅
Background Sync ✅ ❌ ❌ soon Samsung 5
Push Notif. ✅ custom impl. ✅ ✅ Samsung 4
A2HS ✅ ❌ 2020 (Chromium) ❌ Chromium browsers

Service Workers later

They use Service Workers

UX considerations

Show app update and network state

Show stuff syncing (foreground or background sync)

Other UX considerations

Just be kind with your users. :)

Service Worker is a… Web Worker

  • Async, not in the main thread

  • No access to document, window and localStorage

  • Cache API is its buddy

  • For non-cache data storage:

    • ⛔️ localStorage not available from the Service Worker
    • 👍 prefers the use of indexedDB (helper: idb-keyval)

Service Worker lifecycle ♺

What to do during the lifecycle?

  1. First the app fetches and registers the Service Worker file.
  2. Then the Service Worker goes through several states:
    1. installing: the good moment to cache some files!
    2. installed: files are cached, let’s activate!
    3. activating: let’s trash obsolete cache.
    4. activated: take control of tabs, now ready to listen.
    5. idle: listen to events (fetch, push, notificationClick…)
    6. redundant: error, or is being replaced by a fresher SW.

A. Fetch and register the Service Worker

/* main.js */

if (
  'serviceWorker' in navigator
  && !navigator.serviceWorker.controller
) {
  navigator.serviceWorker.register('/service-worker.js')
}

// The same, but in a function and with more explanation

function installServiceWorker () {

  // Check Service Workers support.
  if (!('serviceWorker' in navigator)) { return }

  // Check there’s no Service Worker already controlling the page.
  if (!navigator.serviceWorker.controller) {

    // Fetch and Register Service Worker.
    navigator.serviceWorker.register('/service-worker.js')

    // … or, optionnally with a scope
    navigator.serviceWorker.register('/service-worker.js', {
      scope: 'my/app/scope/',
    })
  }
}

B. Simple cache strategy

Show an offline page

/* service-worker.js */

// Cache name and version.
const CACHE_VERSION = '1.0.0'
const cacheName = `offline-v${CACHE_VERSION}`

// Resources we want to cache.
const offlineResources = [
  '/offline.html',
  'css/offline.css', // used by `offline.html`
]


// Let’s cache during the `install` event

self.addEventListener('install', e => {

  /* Hold the event and open the cache.
   *
   * `waitUntil` prevents the event to be released until
   * the Promise resolves or fails.
   */
  e.waitUntil(caches.open(cacheName)

    // Add resources to the cache.
    .then(cache => cache.addAll(offlineResources))

    // Activates SW now. If you remove this line, the SW will
    // only take control of the page after a page reload.
    .then(() => self.skipWaiting())
  )
})


// A client (= `window`) wants to reach the network

self.addEventListener('fetch', e => {

  // The browser requests HTML content.
  if (
    e.request.method === 'GET'
    && e.request.headers.get('Accept').includes('text/html')
  ) {
    /* respondWith() is the combination of:
     * 1. preventing default fetch()
     * 2. returning a Promise resolving into a Response
     */
    return e.respondWith(

      // Check the network first 🤞
      fetch(e.request)

        // Requested page found 🎉
        .then(response => response)

        // No network, fallback to cached `offline.html`. 💾
        .catch(error => caches.match('/offline.html'))
    )
  }

  // The browser requests other content.
  e.respondWith(
    fetch(e.request)
      .then(response => response)
      .catch(error => caches.match(e.request))
  )
})

B. Simple cache strategy

Cache first, fallback on network

/* service-worker.js */

const resourcesToCache = [
  '/',
  '/contact/',
  'css/app.css',
  'js/app.js',
]

self.addEventListener('fetch', e => {

  // Looking in the cache for a URL matching the requested one
  e.respondWith(caches.match(e.request).then(
    response => response || fetch(e.request)
  ))
})

But…

The most sensitive thing with Service Worker 🧨

“My users can’t update the app,

the Service Worker sticks to the cache…

Users are stuck on an old app version.

Forever.” 😨

:nelson-haha:

💁‍♂️ Reminder

“There are only two hard things in Computer Science:

goddamn cache invalidation

and naming things.” – Phil Karlton

C. Cache update during lifecycle

const CACHE_VERSION = '0.3'
const cacheName = `cache-v${CACHE_VERSION}`

const resourcesToCache = [
  '/',
  'site.webmanifest',

  'css/app.css',
  'js/app.js',
  'storage/data.json',
]

 /* Some functions, used later in event listeners. */

// Fill the cache.
const addResourcesToCache = () => caches.open(cacheName)
  .then(cache => cache.addAll(resourcesToCache))


// A function to clean the old cache.
const flushOldCaches = () => caches.keys()
  .then(keys => Promise.all(keys

    // If the cache version is different, it means it’s an old one.
    .filter(key => !key.endsWith(CACHE_VERSION))

    // Delete the cache matching the keys.
    .map(key => caches.delete(key))
  ))


/* Event listeners. */

// Like before, we add resources to a cache.
self.addEventListener('install', e => {
  e.waitUntil(addResourcesToCache().then(() => self.skipWaiting()))
})

// On Service Worker activation, we free the stale cache.
self.addEventListener('activate', e => {
  e.waitUntil(flushOldCaches().then(() => self.clients.claim()))
})

Other cache strategies, ideas or considerations

  • Split cache in several parts. How?

    • By type of resources (errors pages, fonts, icons, JS vendors…)
    • URL or app architecture (cache for a specific feature or post categories)
    • Consider the update probability and frequency of a resource
  • Look for fresh data signal on app load.

  • More strategies in the old Service Worker Cookbook.

  • Beware of back-end API changes if you cache API requests.

Key principle: don’t cache everything (disk space and bandwidth are precious), progress cautiously.

D. Communication app ↔︎ Service Worker ☎️

Like Web Workers, it’s all about messages. ✏️


/* service-worker.js */

// 3. Helper function to send a message to the main thread.
const notifyClients = data => self.clients.matchAll().then(clients =>
  clients.forEach(client => client.postMessage(data))
)

// 2. Listen to message from the main thread.
self.addEventListener('message', e => {

  if (e.data.action === 'performUpdate') {
    addResourcesToCache()
      .then(() => flushOldCaches())
      .then(() => notifyClients({ dataCacheUpdate: true }))
  }
})

/* main.js */

// 4. Listen to message from the service worker.
navigator.serviceWorker.addEventListener('message', e => {
  if ('dataCacheUpdate' in e.data) {
    toastTheUser('OMG APP UPDATED! 🥳')
  }
})

// 1. Ask an app update to the Service Worker.
navigator.serviceWorker.controller.postMessage({ action: 'performUpdate' })

Other (prefered) option: use the Channel Messaging API.

Dev tools

Easy in Chrome, awful everywhere else

Video

Summary of what we did

  • We can intercept network requests
  • No network? Let’s serve a friendly offline page!
  • Faster loading using the cache, then cache update…
  • … then warn the user about important changes.

People to follow

Tool

Resources

See this gist.

Thanks

meetup.audience.addEventListener('applause', e =>
  e.waitUntil(speaker
    .respondWith(speaker =>
      speaker.incline('5deg').join('🙏')
    )
    .then(speaker =>
      speaker.askOrganizers('Still time for some questions?')
    )
  )
)