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?