Maxim Salnikov

@webmaxru

Service worker:

Taking the best from the past experience for the bright future of PWAs

What did we learn from

3 years of exploring PWA idea?

And where do we go next?

June 15, 2015

Maxim Salnikov

@webmaxru

  • "PWAngelist"

  • PWA Oslo / PWA London meetups, PWA slack organizer

  • Mobile Era / ngVikings conferences organizer

Products from the future

UI Engineer at ForgeRock

Our plan for today

  • PWA status

  • Service worker's life

  • HTTP(S) games

  • Tooling

  • A word about UX

  • Beyond the networking

  • Get ready for the future

#YearOfPWA

Latest updates

After all, what is PWA?

Progressive web apps use modern web APIs along with traditional progressive enhancement strategy to create cross-platform web applications.

These apps work everywhere and provide several features that give them the same user experience advantages as native apps.

Cross-platform?

Browser

Desktop

Mobile

Flagged

OS

#YearOfPWA

Earlier in 2018

UX advantages?

Smart networking + Offline

Proper app experience

Staying notified

Other cool things

}

Service Worker

API

Web App Manifest

Service worker

The ❤️ of PWA

Logically

"Physically"

JS

-file(s)

Web App

Service worker

Browser / WebView

Event-driven worker

Lifecycle

'install'

Parsed

Installing

Activating

Redundant

'activate'

Waiting

Active

Know your toolset

  • Service Worker API

  • Cache API

  • IndexedDB

  • Fetch

  • Clients API

  • Broadcast Channel API

  • Push API

  • Notifications API

  • Local Storage

  • Session Storage

  • XMLHttpRequest

  • DOM

TIP #0

Progressive enhancement

Go for the feature detection

TIP #1

Platforms / browsers support

Registration

if ('serviceWorker' in navigator) {

    // Registering service worker

}

Background syncronization

if ('SyncManager' in window) {

    // Implement offline-ready network features

}

Push subscription

if (!('PushManager' in window)) {

    // Hide UI for Web Push subscription

}

Actions in notifications

if ('actions' in Notification.prototype) {

  // Consider using action buttons

}

Proper time to register

The later the better

TIP #2

if ('serviceWorker' in navigator) {

        navigator.serviceWorker.register('/sw-workbox.js')
            .then(...);

}
if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw-workbox.js')
            .then(...);
    });
}
platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .then(() => {

    // Service worker registration

  });

main.ts

In case of emergency

Implement a Kill Switch

TIP #3

  • Deploy fixed or no-op service worker

  • ... or unregister service worker

  • Make sure that browser will not serve service worker file(s) from HTTP cache

Rescue plan

No-op

self.addEventListener('install', () => {
  self.skipWaiting();
});
self.addEventListener('activate', () => {
  self.clients.matchAll({type: 'window'}).then(tabs => {
    tabs.forEach(tab => {
      tab.navigate(tab.url);
    });
  });
});

UX breaking

Unregister

navigator.serviceWorker.getRegistrations()
    .then((registrations) => {
        for(let registration of registrations) {
            registration.unregister()
        }
    })
self.addEventListener('activate', event => {
  event.waitUntil(self.clients.claim());
  self.registration.unregister();
});

service-worker.js

Chicken-and-egg

Update service worker

Cache-Control: no-cache

Assets from importScripts()

Main service worker

Spec was updated

Byte-difference check - add versioning via file content

navigator.serviceWorker.register(`/sw.js?v=${VERSION}`);

Byte check doesn't work  - add versioning via filename of imported SW or main SW

updateViaCache

index.html

navigator.serviceWorker.register('/sw.js', {
  updateViaCache: 'none'
})

Values: "imports", "all", or "none"

Pre-caching

Trust your assets and be intolerant to the outdated ones

TIP #4

const appShellFilesToCache = [
  ...
  './non-existing.html'
]

sw-handmade.js

self.addEventListener('install', (event) => {

  event.waitUntil(
    caches.open(cacheName).then((cache) => {
      return cache.addAll(appShellFilesToCache)
    })
  )

})
  • HTTP errors

  • Service worker execution time

  • Storage errors

Chrome <6% of free space
Firefox <10% of free space
Safari <50MB
IE10 <250MB
Edge Dependent on volume size

Storage is not unlimited

if ('storage' in navigator && 'estimate' in navigator.storage) {
  navigator.storage.estimate().then(({usage, quota}) => {
    console.log(`Using ${usage} out of ${quota} bytes.`);
  });
}

Caching from other origins

Get ready for opaque

TIP #5

2 options

  • Add CORS headers on remote side

  • Handle opaque responses

Opaque responses limitations

  • Valid for limited set of elements: <script>, <link rel="stylesheet">, <img>, <video>, <audio>, <object>, <embed>, <iframe>

  • The status property of an opaque response is always set to 0, regardless of whether the original request succeeded or failed

  • The Cache API's add()/addAll() methods will both reject if the responses resulting from any of the requests have a status code that isn't in the 2XX range

const appShellFilesToCache = [
  ...
  'https://workboxjs.org/offline-ga.min.svg'
]

sw-handmade.js

self.addEventListener('install', (event) => {

  event.waitUntil(
    caches.open(cacheName).then((cache) => {
      return cache.addAll(appShellFilesToCache)
    })
  )

})

Solution for no-cors

const noCorsRequest =
    new Request('https://workboxjs.org/offline-ga.svg', {
        mode: 'no-cors'
    });

fetch(noCorsRequest)
    .then(response => cache.put(noCorsRequest, response));

Redirects

To follow or not to follow?

TIP #6

self.addEventListener('install', (event) => {

  event.waitUntil(
    caches.open(cacheName).then((cache) => {
      return cache.addAll(appShellFilesToCache)
    })
  )

})
const appShellFilesToCache = [
    ...
    './assets/redirect/redirectfrom.html'
]

sw-handmade.js

app.get('/assets/redirect/redirectfrom.html', (req, res) => {
    res.redirect(301, '/assets/redirect/redirectto.html')
})

server/index.js

If one of the following conditions is true, then return a network error:

  • ...

  • request’s redirect mode is not "follow" and response’s url list has more than one item.

Redirect mode of navigation request is “manual”

  • To avoid the risk of open redirectors introduce a new security restriction which disallows service workers to respond to requests with a redirect mode different from "follow".

  • Add .redirected attribute to Response class of Fetch API. Web developers can check it to avoid untrustworthy responses.

  • Do not pre-cache redirected URLs

  • "Clean" the response before responding

Solutions for 3xx

Do not pre-cache 3xx at all

/dashboard

/dashboard

/login

// If "cleanRedirects" and this is a redirected response,
// then get a "clean" copy to add to the cache.

const newResponse = cleanRedirects && response.redirected ?
    await cleanResponseCopy({response}) :
    response.clone();

workbox/.../request-wrapper.js#L420

"Clean" the response

Proper tools

Be there or be square

TIP #7

Tools help with

  • Implementing complex algorithms

  • Adopting best practices

  • Focusing on YOUR task

  • Following specifications updates

  • Handling edge cases

Frameworks

  • sw-precache / sw-toolbox

  • Workbox

  • offline-plugin for Webpack

  • PWABuilder.com

  • create-react-app

  • preact-cli

  • polymer-cli

  • vue-cli

  • angular-cli

Builders

  • Lighthouse

  • Sonarwhal

Audit / Linting

App shell

Runtime caching

Offline GA

Replay failed requests

Broadcast updates

Build integrations

Possibility to extend your own service worker instead of using generated one

Manifest checker & generator

Service worker builder

Manifest icons generator

Generating the projects you need to build native apps for the stores

I have one "real" codebase, the PWA. Then I have 3 "wrapper" projects: Windows UWP app, Java app for Android Studio, Objective C app for XCode. All 3 wrapper projects load up the PWA.

npm install -g sonarwhal

sonarwhal --init

sonarwhal https://airhorner.com
npm install -g lighthouse

lighthouse https://airhorner.com

Testing

Service Worker Mock

const env = {
  // Environment polyfills
  skipWaiting: Function,
  caches: CacheStorage,
  clients: Clients,
  registration: ServiceWorkerRegistration,
  addEventListener: Function,
  Request: constructor Function,
  Response: constructor Function,
  URL: constructor Function
};

App Update

Don't break the web

TIP #8

App version updates

v1

v2

v1

v1

v2

Server

Browser

v2

A newer version of the app is available. Refresh

App update strategies

  • Check on app start

  • Check periodically

  • Check on navigation requests

const updatesChannel = new BroadcastChannel('precache-updates');

updatesChannel.addEventListener('message', event => {

  console.log('Cache updated', event.data.payload.updatedUrl);

  // Show a prompt "New version is available. Refresh?"

});
workbox.precaching.addPlugins([
    new workbox.broadcastUpdate.Plugin('precache-updates')
]);

main.js

service-worker.js

App Installation

Choose the proper moment

TIP #9

  • Show up moment is unpredictable. Heuristics is still under adjustment.​

  • Interrupting user experience (incl. covering the content on mobile device)

  • Well hidden way to do it manually

  • Not very discoverable alternative: ambient badging

Default prompt

Ongoing discussion

Show your own UI

main.js

window.addEventListener("beforeinstallprompt", event => {

  // Suppress automatic prompting.
  event.preventDefault();

  // Show custom install button
  installButton.classList.remove('hidden');

  // Bind onclick event - on the next slide

});

Show native prompt

main.js

 

installButton.addEventListener("click", e => {

  event.prompt(); // event - from beforeinstallprompt
  event.userChoice
    .then( choiceResult => {

      console.log(`Choice is: ${choiceResult.outcome}`);

      // Hide custom install button      
      installButton.classList.add('hidden');

    })
});

Track all possible ways

main.js

window.addEventListener("appinstalled", event => {

  console.log('The app was installed')

  // Hide custom install button
  installButton.classList.add('hidden');

});
  • Give the developer ability to show installation prompt at any time?

  • Install the app without visiting the website

  • Push the app directly to the devices

Open questions

Push notifications

Use responsibly 

TIP #10

  • Initiate only after explicit user action

  • Keep unsubscribe functionality visible 

Subscription

Allow users to unsubscribe.

Otherwise they'll block!

Notifications

  • Consider alternative ways first - displaying notification is the last resort

  • Not for broadcasting, but for individual approach

MyAirline

Online check in is available

MyAirline

myairline.com

Flight

DY1043

Depart

21.09 13:45

OSL -> LAX

Click here to check in now

myairline.com

Content

  • Do not Repeat Yourself

  • Provide actual information instead of notifying about it

  • Give a call to action

Beyond the caching

Use the full potential of the service worker

TIP #11

Load balancer

  • Intercept the requests to the resources and select the proper content provider

  • To choose a server with the least load, to test new features, to do A/B testing

Client-side downloads

service-worker.js

self.addEventListener('fetch', function(event) {

  if(event.request.url.indexOf("download-file") !== -1) {
    event.respondWith(event.request.formData().then( formdata => {

      var response = new Response(formdata.get("filebody"));
      response.headers.append('Content-Disposition',
        'attachment; filename="' + formdata.get("filename") + '"');
      return response;

    }));
  }

});

WebP support (with WASM)

service-worker.js

event.respondWith(async function() {

  const response = await fetch(event.request);
  const buffer = await response.arrayBuffer();

  const WebPDecoder = await fetchWebPDecoder();
  const decoder = new WebPDecoder(buffer);
  const blob = await decoder.decodeToBMP();

  return new Response(blob, { headers: { "content-type": "image/bmp",
    "status": 200 } });

}());

Upcoming features

Get prepared

TIP #12

Navigation preload

SW Boot

Navigation request

SW Boot

Navigation request

self.addEventListener('activate', e => {
  e.waitUntil(self.registration.navigationPreload.enable());
});
self.addEventListener('fetch', event => {
  event.respondWith(async function() {
    // Respond from the cache if we can
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    // Else, use the preloaded response, if it's there
    const response = await event.preloadResponse;
    if (response) return response;

    // Else try the network.
    return fetch(event.request);
  }());
});

Periodic sync

  • Restricted by time interval, battery state and network state

  • Would require user permission

  • Don't require any server configuration

  • Allow the user agent to optimize when they fire

navigator.serviceWorker.ready.then((registration) => {
  registration.periodicSync.register({
    tag: 'get-latest-news',         // default: ''
    minPeriod: 12 * 60 * 60 * 1000, // default: 0
    powerState: 'avoid-draining',   // default: 'auto'
    networkState: 'avoid-cellular'  // default: 'online'
  }).then((periodicSyncReg) => {

    // Successfully registered

  })
});

index.html

self.addEventListener('periodicsync', function(event) {
  if (event.registration.tag == 'get-latest-news') {
    event.waitUntil(fetchAndCacheLatestNews());
  }
  else {
    // Unknown sync, may be old, best to unregister
    event.registration.unregister();
  }
});

sw-handmade.js

Background fetch

  • Fetches (requests & responses) are alive after user closes all windows & worker to the origin

  • Browser/OS shows UI to indicate the progress of the fetch, and allow the user to pause/abort

  • Dealing with poor connectivity by pausing/resuming the download/upload

  • App has an access to the fetched resources and to the status/progress of the fetch 

const registration = await navigator.serviceWorker.ready;
const bgFetchJob = 
  await registration.backgroundFetch.fetch(id, requests, options);
addEventListener('backgroundfetched', event => {
  event.waitUntil(async function() {
    const fetches = await event.fetches.values();

    // Put all fetch responses to the cache
    ...
  }());
});

main.js

service-worker.js

Payment handler

  • A helper for Payment Request API specifically for the web payment apps

  • Registers some payment instruments (card payments, crypto-currency payments, bank transfers, etc)

  • On payment request user agent computes a list of candidate payment handlers, comparing the payment methods accepted by the merchant with those supported by registered payment handlers

const swReg = await navigator.serviceWorker.register("/sw.js");
await swReg.paymentManager.paymentInstruments.set(
  "c8126178-3bba-4d09-8f00-0771bcfd3b11",
  {
    name: "My Bob Pay Account: john@example.com",
    method: "https://bobpay.com",
    icons: [{ ... }]
  }
);
self.addEventListener("paymentrequest", event => {
  // Do the payment flow
  // Open window if needed
});

main.js

service-worker.js

  • 1800+ developers

  • Major browsers/frameworks/libs devs

TIP #13

Thank you!

@webmaxru

Maxim Salnikov

Questions?

Service Worker: taking the best from the past experience for the bright future of PWAs

By Maxim Salnikov

Service Worker: taking the best from the past experience for the bright future of PWAs

There is no doubt that 2018 is the year when Progressive Web Apps will get the really broad adoption and recognition by all the involved parties: browser vendors (finally, all the major ones), developers, users. And the speed and smoothness of this process heavily depend on how correctly we, developers, use the power of new APIs. The main one in PWA concept is Service Worker API, which is responsible for all offline magic, network optimizations and push notifications. In my session based on accumulated experience of developing and maintaining PWAs we - go through the list of advanced tips & tricks, - showcase best practices, - learn how to avoid common pitfalls, - have a look at the latest browser support and known limitations, - share lots of useful insights. All on the way to the #YearOfPWA, all for delighting our users by the truly modern web applications.

  • 6,100