Progressive Web Apps

Looks Like 🥒 · Tastes Like 🥓

by Jason Lengstorf
@jlengstorf | jason@lengstorf.com

Slides: git.io/v9tEr

How much does your websitecost?

We measure cost in seconds:

seconds to load popular websites

Source: Time To Interactive from Lighthouse. I ran the test twice and took the faster time.

Time isn’t the only cost

Mobile Data Can Be Expensive

$2.05/MB

Price in USD for a US-based AT&T customer traveling internationally.

Websites are getting larger

website size from 2012 to 2017

Source: HTTP Archive

Would you pay $10
to load this website?

cost in USD to load popular websites

Also: loading huge sites on mobile just sucks

Chrome’s no connection screen

...and what if the connection is flaky?

We Can Fix It!

(And it doesn’t mean starting from scratch.)

The Solution:

Progressive Web Apps

With Progressive Web Apps (PWAs), we can:

  1. Significantly decrease the amount of data transferred on each page load
  2. Improve the experience for users on unreliable connections
boooooring

Booooooooooooooring!

“That’s not my target market.”

eat your veggies

Can’t we just ignore PWAs?

Nope

Here’s why:

  • PWAs decrease load times on all connections
  • Perceived load times improve
  • Stability improves for mobile users everywhere
  • User experience is better all around
bacon

That sounds significantly more awesome than eating vegetables.

Let’s Build a Simple Progressive Web App

  1. Register a Service Worker
  2. Cache required resources on install
  3. Serve cached resources when available
  4. Fall back to the network
  5. Cache network responses
  6. Add a fallback for image files
  7. Add a default fallback page
  8. Create an app manifest
  9. Add app icons
  10. Add the manifest to the HTML

Clone the repo:

# Get a local copy of the starter code.
git clone git@github.com:jlengstorf/pwa-simple.git

# Move into the cloned directory.
cd pwa-simple/

1. Register a Service Worker

<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./service-worker.js')
      .then(() => {
        console.log(`ServiceWorker registered!`);
      })
      .catch(error => {
        console.error('ServiceWorker error:', error);
      })
  }
</script>

NOTE: The Service Worker must be created at the root domain.
Example: https://example.org/service-worker.js

2. Install App Resources

const CACHE_VERSION = 'sample-pwa-v1';

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_VERSION).then(cache => {
      // Download all required resources to render the app.
      return cache.addAll([
        './index.html',
        './scripts.js',
        './styles.css',
      ]);
    })
  );
});

3. Use the Cache By Default

self.addEventListener('fetch', event => {
  event.respondWith(
    caches
      // Check for cached data.
      .match(event.request)
      // Return the cached data if it exists.
      .then(data => data)
  );
});

4. Fall Back to the Network

  self.addEventListener('fetch', event => {
    event.respondWith(
      caches
        // Check for cached data.
        .match(event.request)
-       // Return the cached data if it exists.
-       .then(data => data)
+       // Return the cached data OR hit the network.
+       .then(data => data || fetch(event.request))
    );
  });

5a. Cache New Responses

  self.addEventListener('fetch', event => {
+   const fetchAndCache = request =>
+     caches.open(CACHE_VERSION).then(cache =>
+       // Load the response from the network.
+       fetch(request).then(response => {
+         // Add the response to the cache.
+         cache.put(request, response.clone());
+         return response;
+       })
+     );

    event.respondWith(
      /* the rest of the code is skipped to save space */
  });

5b. Cache New Responses

  self.addEventListener('fetch', event => {
    const fetchAndCache = request =>
      /* the rest of the code is skipped to save space */

    event.respondWith(
      caches
        // Check for cached data.
        .match(event.request)
        // Return the cached data OR hit the network.
-       .then(data => data || fetch(event.request))
+       .then(data => data || fetchAndCache(event.request))
    );
  });

6a. Add a Fallback for Images

  const CACHE_VERSION = 'sample-pwa-v1';
+ const OFFLINE_IMAGE = './offline.png';

  self.addEventListener('install', event => {
    event.waitUntil(
      caches.open(CACHE_VERSION).then(cache => {
        // Download all required resources to render the app.
        return cache.addAll([
          './index.html',
          './scripts.js',
          './styles.css',
+         OFFLINE_IMAGE,
        ]);
      })
    );
  });

6b. Add a Fallback for Images

    event.respondWith(
      caches
        // Check for cached data.
        .match(event.request)
        // Return the cached data OR hit the network.
        .then(data => data || fetchAndCache(event.request))
+       .catch(() => {
+         const url = new URL(event.request.url);
+
+         // Show the fallback image for failed GIF requests.
+         if (url.pathname.match(/\.gif$/)) {
+           return caches.match(OFFLINE_IMAGE);
+         }
+       })
    );

7a. Add a Default Fallback Page

  const CACHE_VERSION = 'sample-pwa-v1';
  const OFFLINE_IMAGE = './offline.png';
+ const OFFLINE_PAGE = './offline.html';

  self.addEventListener('install', event => {
    event.waitUntil(
      caches.open(CACHE_VERSION).then(cache => {
        // Download all required resources to render the app.
        return cache.addAll([
          './index.html',
          './scripts.js',
          './styles.css',
          OFFLINE_IMAGE,
+         OFFLINE_PAGE,
        ]);
      })
    );
  });

7b. Add a Default Fallback Page

        .catch(() => {
          const url = new URL(event.request.url);
 
          // Show the fallback image for failed GIF requests.
          if (url.pathname.match(/\.gif$/)) {
            return caches.match(OFFLINE_IMAGE);
          }

+         // Show an offline page for other failed requests.
+         return caches.match(OFFLINE_PAGE);
        })

8. Create an App Manifest

{
  "name": "Progressive Web App Demo for Web Summer Camp 2017",
  "short_name": "#websc PWA",
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#faf8fd",
  "theme_color": "#663399"
}

9. Add App Icons

    "theme_color": "#663399",
+   "icons": [
+     {
+       "src": "./pwa-icon.png",
+       "type": "image/png",
+       "sizes": "192x192"
+     },
+     {
+       "src": "./pwa-icon-512.png",
+       "type": "image/png",
+       "sizes": "512x512"
+     }
+   ]
  }

10. Add the Manifest to the HTML

  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
+   <meta name="theme-color" content="#663399">
+   <link rel="manifest" href="./manifest.json">
    <link rel="stylesheet" href="./styles.css">
    <title>Simple Progressive Web App</title>
  </head>

Bonus Round: Use sw-precache

Google

We don’t have to
start from scratch.

Let’s turn this app into a PWA:

bear app

Download the source:

# clone the source repo
git clone git@github.com:jlengstorf/pwa-workshop-starter.git

# move into the app directory
cd pwa-workshop-starter

# install dependencies with yarn
npm install

Right now, it doesn’t score very well:

Lighthouse score for non-PWA

Lighthouse is Google’s plugin to audit apps for PWA features.

To build PWA we need to:

  • Register a Service Worker to manage caching and offline access
  • Create an app manifest to describe the app
  • Let the Service Worker know about required assets
  • Define caching rules for other assets

Bonus points for:

  • Lazy loading resources to improve perceived performance
  • Enabling offline support for user interactions

...that sounds like a lot of work.

It’s not

Most of the process is already done for you

Step 1: Install
sw-precache-webpack-plugin

npm install -D sw-precache-webpack-plugin
  • Uses the excellent sw-precache tool from the Google Chrome team
  • Creates the Service Worker
  • Accepts a list of files or folders and generates required asset list
  • Accepts a list of URL patterns to manage caching of other assets
  • Enables offline access

Configuring sw-precache-webpack-plugin:

plugins: [
  new SWPrecacheWebpackPlugin(
    {
      filepath: './dist/service-worker.js',
      runtimeCaching: [{
        urlPattern: /[.]jpg$/,
        handler: 'cacheFirst'
      }],
      staticFileGlobs: [
        'dist/assets/{css,js}/main.{css,js}',
        'dist/assets/*.{html,png,xml,ico,svg}'
      ],
      stripPrefix: 'dist/',
    }
  ),
],

github.com/goldhand/sw-precache-webpack-plugin

Step 2: Register the Service Worker in your app

<script>
  (function() {
    if('serviceWorker' in navigator) {
      navigator.serviceWorker.register('./service-worker.js')
        .then(reg => {
          console.log('Added Service Worker at', reg.scope);
        })
        .catch(error => {
          console.error(error);
        });
    }
  })();
</script>

Step 3: Generate a manifest

  1. Go to realfavicongenerator.net
  2. Follow the instructions
  3. Done!

NOTE: We can automate this with an NPM package.

Step 4: ...

Technically, we’re all done at this point.

Bonus: Add lazy loading

Lazy-loading with responsive-lazyload.js:

<a href="img/bacon.jpg"
   class="gallery__link js--lazyload">
  <img class="gallery__image"
       src="img/thumbs/bacon.jpg"
       srcset="data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
       data-lazyload="img/thumbs/bacon.jpg 1x,
                      img/thumbs/bacon@2x.jpg 2x"
       alt="image">
</a>

github.com/jlengstorf/responsive-lazyload.js

This score is much better:

Lighthouse PWA score

Let’s Recap

Progressive Web Apps:

  • Make our apps faster
  • Improve the user experience on all devices
  • Increase the reliability of our app
  • Allow our users to “install” our app on their home screen

All with a few minutes of effort.

And with just a bit of extra work, we can lazy load resources to further improve load times.

Progressive Web Apps are Awesome

With very little effort, we can build web apps that feel just as snappy as native apps

It’s not

🥕🥒🌽

It’s

🥓🥓🥓

Resources

  1. Jake Archibald’s Offline Cookbook
  2. Lighthouse
  3. sw-precache
  4. sw-precache-webpack-plugin
  5. Favicon Generator (for manifest.json)
  6. responsive-lazyload.js
  7. PouchDB

Tweet: @jlengstorf #websc