The PWA Wiki logo

PWA Wiki


Service worker

Getting started with Workbox

Include this inside your Service Worker file (e.g. sw.js)

importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js');

if (workbox) {
  console.log(`Yay! Workbox is loaded 🎉`);
} else {
  console.log(`Boo! Workbox didn't load 😬`);
}

Caching

Workbox Precaching

workbox.precaching.precacheAndRoute(['/', 'index.html'], 'GET');

Workbox Caching Strategies

💡
Default Cache Strategy is CacheFirst
workbox.routing.registerRoute(
  /\.(?:js|css)$/,
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'css_js'
  })
);

Workbox Preloading Routes

Registration

/* app.js */
const SERVICE_WORKER_SCOPE = '/';

window.addEventListener('load', async () => {
    if ('serviceWorker' in navigator) {
        const registration = await navigator.serviceWorker.register('./service-worker.js', { scope: SERVICE_WORKER_SCOPE });
    }
});

Updating

💡
By default, a page's fetches won't go through a service worker unless the page request itself went through a service worker. So you'll need to refresh the page to see the effects of the service worker. clients.claim() can override this default, and take control of non-controlled pages
workbox.skipWaiting();
workbox.clientsClaim();

Install event

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(cacheName).then(function(cache) {
      return cache.addAll(
        [
          '/css/main.min.css',
          '/js/app.min.js',
          '/offline.html'
        ]
      );
    })
  );
});

Activate event

self.addEventListener('activate', async event => {

  await self.registration.navigationPreload.enable();

  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          // Return true if you want to remove this cache,
          // but remember that caches are shared across
          // the whole origin
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

Fetch event

// when the browser requests a resource
addEventListener('fetch', event => {
    event.respondWith(
        // look in the cache for the resource
        caches.match(event.request).then(async response => {
            if (response) {
                // is in cache, respond with the cached resource
                return response;
            }
            // if not found fetch it from the network
            const networkResponse = await fetch(event.request);
            // response needs to be cloned if going to be used more than once
            const clonedResponse = networkResponse.clone();
            
            // save response to runtime cache for later use
            const runtimeCache = await caches.open('runtime-cache');
            runtimeCache.put(event.request, networkResponse);
            
            // respond with the cloned network response
            return Promise.resolve(clonedResponse);
        })
    );
});

Offline

Default offline page

Workbox

importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js');

const OFFLINE_HTML = '/offline.html';
const PRECACHE = [
  OFFLINE_HTML
];

workbox.precaching.precacheAndRoute(PRECACHE);

const htmlHandler = new workbox.strategies.NetworkOnly();
// A NavigationRoute matches navigation requests in the browser, i.e. requests for HTML.
const navigationRoute = new workbox.routing.NavigationRoute(({ event }) => {
  const request = event.request;
  return htmlHandler.handle({ event, request }).catch(() => caches.match(OFFLINE_HTML, {
    ignoreSearch: true
  }));
});
workbox.routing.registerRoute(navigationRoute);

Vanilla JS

addEventListener('fetch', async event => {
    if (event.request.mode === 'navigate') {
      event.respondWith((async () => {
        const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();
        const url = event.request.url;
        
        try {
          // check if the event.request.url exists in the cache or in the network
          const response = await caches.match(event.request) || await fetch(event.request);
          if (!response || response.status === 404) {
            throw new Error(response.status);
          } else {
            return await staleWhileRevalidate.handle({event});
          }
    
        } catch (error) {
          // if not found in any of the two, respond with the offline.html file
          console.warn(`ServiceWorker: ${url} was not found either in the network or the cache. Responding with offline page instead.\n`);
          return await caches.match(OFFLINE_PAGE_URL) || await fetch(OFFLINE_PAGE_URL, { method: 'GET' });
    
        }
      })());
    }
});

Mark offline unavailable pages

  1. Register a route for pages to cache - service-worker.js
    // * store projects' media in a different runtime cache
    // * checking later inside this cache to decide which projects are available offline
    workbox.routing.registerRoute(
      /\.(?:png|gif|webp|mp4|webm)$/,
      new workbox.strategies.CacheFirst({
        cacheName: 'runtime-pages-media'
      })        
    );
  1. Mark pages when offline - app.js
    1. Check offline status
      • on startup / window load
      if (!navigator.connection.downlink) {
      	// is offline
      }
      • on change of network conditions
      window.addEventListener('offline', markOfflineUnavailablePages);
      window.addEventListener('online', unmarkOfflineUnavailablePages);
    1. Handle offline event
      const markOfflineUnavailableProjects = () => {
        const pages = document.querySelectorAll('a.page-link');
      
        caches.open('runtime-pages-media').then(pagesRuntimeCache => {
          pages.forEach(page => {
            pagesRuntimeCache.keys().then(keys => {
              const cachedPage = keys.find(key => key.url === page.href);
              
              if (!cachedPage) {
                // mark page as unavailable while offline
                product.classList.add('unavailable-offline');
              } else {
                // add the page to a list of offline available pages
              }
      
            });
          });
        });
      }
  1. Add styling - styles.css
    .unavailable-offline {
      opacity: 0.25; 
      pointer-events: none; 
    }

Push notifications

Push examples website

Glitch Repo


Background Sync

Vanilla JavaScript

Replaying offline requests example website

Workbox

Glitch repo


Placeholder image fallback

const placeholderImageURL = '/img/placeholder-image.png'; // precache this in __precacheManifest file
workbox.precaching.precacheAndRoute(self.__precacheManifest || []);

workbox.routing.registerRoute(
  /\.(?:webp|png|jpg|jpeg|svg)$/,
  async ({url, event, params}) => {
    const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();

    try {
      const response = await caches.match(event.request) || await fetch(url, { method: 'GET' });
      if (!response || response.status === 404) {
        throw new Error(response.status);
      } else {
        return await staleWhileRevalidate.handle({event});
      }

    } catch (error) {
      console.warn(`\nServiceWorker: Image [${url.href}] was not found either in the network or the cache. Responding with placeholder image instead.\n`);
      // * get placeholder image from cache || get placeholder image from network
      return await caches.match(placeholderImageURL) || await fetch(placeholderImageURL, { method: 'GET' });

    }
  }
);

Offer reload on new version

Custom pull-to-refresh effect


Install - Add to Home screen

Install button

NPM

  1. Install npm package: npm install @pwabuilder/pwainstall
  1. Import inside your app scripts: import '@pwabuilder/pwainstall'

CDN

  1. Include module in index.html:
    <script
      type="module"
      src="https://cdn.jsdelivr.net/npm/@pwabuilder/pwainstall"
    ></script>

Then you can use the element <pwa-install></pwa-install> inside your html files.

Custom implementation

Register service worker

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
		.register('/sw.js', { scope: '/' })
	  .then(registration => {
	    
	  });
}

Catching Install event

let deferredPromptEvent;

window.addEventListener('beforeinstallprompt', function(e) {
  e.preventDefault(); 
  deferredPromptEvent = e; 
  installButton.style.display = 'block';
});

Install button event listener

const installButton = document.getElementById('install-button');

installButton.addEventListener('click', function() {
  installButton.style.display = 'none';
  deferredPromptEvent.prompt();

  deferredPromptEvent.userChoice.then(function(choiceResult) {
    console.log(choiceResult.outcome) // 'dismissed' or 'accepted'
      deferredPromptEvent = null;
  });
});

Install banner for iOS (≤ 13)

// Detects if device is an iOS (including iOS 13) 
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

// Detects if device is in standalone mode
const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;

// Checks if should display install popup notification:
if (isIos && !isInStandaloneMode) {
  this.setState({ showInstallMessage: true });
}

Web Manifest


Fullscreen mode on iOS

<meta name=”apple-mobile-web-app-capable” content=”yes”>

Design

Icon Generators

Favicons - Maskable icon

...
  "icons": [
    {
      "src": "./favicon/favicon-16x16.png",
      "sizes": "16x16",
      "type": "image/png"
    },
    {
      "src": "./favicon/favicon-32x32.png",
      "sizes": "32x32",
      "type": "image/png"
    },
    {
      "src": "./favicon/favicon-64x64.png",
      "sizes": "64x64",
      "type": "image/png"
    },
    { 
      "src": "./favicon/android-chrome-192x192.png", 
      "sizes": "192x192", 
      "type": "image/png" 
    },
    { 
      "src": "./favicon/android-chrome-512x512.png", 
      "sizes": "512x512", 
      "type": "image/png" 
    },
    {
      "src": "./favicon/maskable_icon.png",
      "sizes": "196x196",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
...

Windows

<meta name="msapplication-square70x70logo" content="icon_smalltile.png">
<meta name="msapplication-square150x150logo" content="icon_mediumtile.png">
<meta name="msapplication-wide310x150logo" content="icon_widetile.png">

iOS

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Example PWA">
<link href="icon-152x152.png" rel="apple-touch-icon">

iOS Splash screen

<link href="/apple_splash_2048.png" sizes="2048x2732" rel="apple-touch-startup-image" />
<link href="/apple_splash_1668.png" sizes="1668x2224" rel="apple-touch-startup-image" />
<link href="/apple_splash_1536.png" sizes="1536x2048" rel="apple-touch-startup-image" />
<link href="/apple_splash_1125.png" sizes="1125x2436" rel="apple-touch-startup-image" />
<link href="/apple_splash_1242.png" sizes="1242x2208" rel="apple-touch-startup-image" />
<link href="/apple_splash_750.png" sizes="750x1334" rel="apple-touch-startup-image" />
<link href="/apple_splash_640.png" sizes="640x1136" rel="apple-touch-startup-image" />

Using system fonts

index.html

<!-- ! Download Google fonts only if launched in the browser -> Else use system fonts -->
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet" media="all and (display-mode: browser)" />

styles.css

/* iOS */
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;

/* Android */
font-family: 'RobotoRegular', 'Droid Sans', sans-serif;

/* Windows Phone */
font-family: 'Segoe UI', Segoe, Tahoma, Geneva, sans-serif;

Github example

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}

Web Share API


HTML Streaming

Presentation by Philip Walton

  • Creating an app shell
  • Loading different parts of the website separately (navigation/app shell from the cache, content from server from NetworkFirst)


Navigation Preload

Workbox

workbox.navigationPreload.enable();

Vanilla


TWA

APK Generators

Notifications


Web Perception API

Example and getting started guide


Tools & Resources

PWA Starter Kit

PWA Builder CLI

PWA and isomorphic rendering (SSR)

Progressive Web Components

PWA Technical Overview

Permissions site


Troubleshooting

Workbox v5

In v5, the glob-related configuration options are no longer supported. The webpack asset pipeline is the source of all the automatically created manifest entries. (Developers who have files that exist outside of the webpack asset pipeline are encouraged to use, e.g., copy-webpack-plugin to get those files into the webpack compilation.)

Reading pre-cached files directly

it's still a best practice to call getCacheKeyForURL() to determine the actual cache key, including the __WB_REVISION parameter, if you need to access precached entries using the Cache Storage API directly.

import {cacheNames} from 'workbox-core';
import {getCacheKeyForURL} from 'workbox-precaching';

const cache = await caches.open(cacheNames.precache);
const response = await cache.match(
  getCacheKeyForURL('/precached-file.html')
);

Alternatively, if all you need is the precached Response object, you can call matchPrecache(), which will automatically use the correct cache key and search in the correct cache:

import {matchPrecache} from 'workbox-precaching';

const response = await matchPrecache('/precached-file.html');

UI Fixes

Scroll into view when keyboard is shown on mobile

Element.scrollIntoView()

Common issues

Workbox new version

event.respondWith()

Serving Cached Video files on Safari (Range requests)


Common Questions