We built a PWA from scratch - This is what we learned

Progressive Web Apps bring a lot of potential to the web. The new capabilities make it possible to build native-like web apps that integrate closer with people’s devices.

To try out these abilities we built a Progressive Web App (PWA) from scratch. We already had a great idea for an app.

Our Progressive Web App

The plan

On top of making an useful app, these were our goals:

  • To create a Service Worker to boost performance and make the app available offline.
  • To make sure an “Add to Home Suggestion” would pop-up for users on Android, to access it like a native app.
  • To nicely decorate the app outside the viewport, using a Manifest file.
  • To get a 100% rating with Lighthouse, to check for the PWA best practices.
  • Open Source the code on Github and write a blog post about the lessons we learned (you are reading it now)

Workers at your service

It took a bit of a time to grasp Service Workers. It’s not like any other technology we’ve had on the web, until now.

They’re a part of the web-app, yet they’re not able to touch the DOM in any way. Service Workers are a proxy that live between the web-app and the user. They work in a separate thread and can cache assets, hook into requests, and communicate with the website via messages.

Service Workers are installed on the user browser after the page has loaded for the first time. After the first install event, the worker can start interfering with requests using the fetch event. When a new version of the Service Worker is deployed, the browser will automatically detect byte differences and activate the worker version on next visit.

A Service Worker Boilerplate:

self.addEventListener('install', (event) => {
  // Perform install steps
})

self.addEventListener('fetch', (event) => {
 // Handle requests
})

self.addEventListener('activate', (event) => {
  // Clean up old cache versions
})

We built our Service Worker mostly based on a Smashing Magazine article about it. We found this article to provide the most complete example to fit our needs.

For simple cases, it’s also possible to use a plugin called sw-precache for caching purposes, but we wanted to get into the grunts of it and craft our own from the ground.

Caching gotchas

We start by caching all assets when the install event fires.

const urlsToCache = [
    '/',
    '/style.caf603aca47521f652fd678377752dd0.css',
    '/main-compiled.9b0987235e60d1a3d1dc571a586ce603.js',
    '/fonts/swedensans-webfont.woff',
    '/fonts/swedensans-webfont.woff2'
]

// Open cache and store assets
self.addEventListener('install', (event) => {
  // Perform install steps
  event.waitUntil(
    caches.open('vecka-14islands-com-cache-v1')
      .then((cache) => {
        return cache.addAll(urlsToCache);
      })
  )
})

The tricky part is that there is more than just one cache. The browser also has its cache and in our case, we use a global CDN for better performance.

Lets take an example, a JavaScript or a CSS files changes, how do we make sure the new version is used by the browser?

A way would be to use a HTTP header with Cache-Control: no-cache on these assets to make sure the file is fetched on every request. Problem is, we miss out of all the benefits of browser and CDN catching.

The solution we used is not a new one, to append version numbers to these files that are subject to change.

'/style.caf603aca47521f652fd678377752dd0.css',
'/main-compiled.9b0987235e60d1a3d1dc571a586ce603.js',

This way we can keep a long max-age HTTP headers with Cache-Control: max-age=31536000 and enjoy the benefits of browser/CDN caching. I recommend Jake Archibald post about this to dive deeper, he is the biological father of Service Workers.

Make sure to include the same version numbers in the HTML as in the Service Worker:

<link rel='stylesheet' href="/style.caf603aca47521f652fd678377752dd0.css" />
<script src="/main-compiled.9b0987235e60d1a3d1dc571a586ce603.js"></script>

In our app we used a NodeJS module called Stacify to automatically create new version numbers in all the places when a file is changed.

Caching strategy

For our caching strategy we had to consider two kinds of content, static assets and a dynamic page HTML.

Our static assets include CSS, JavaScript and Font files. For these we use a cache first strategy, this means we’ll fetch first from the cache before requesting the page.

respondFromCacheThenNetwork (event) {
  // Check cache first, then network
  const request = event.request
  event.respondWith(
    fetchFromCache(event)
      .catch(() => fetch(request))
      .then(response => addToCache(request, response))
      .catch(() => offlineResponse())
    )
}

Since the dynamic page HTML might change each time you open the app, we used a network first strategy for it, checking the network first before the cache.

respondFromNetworkThenCache (event) {
  // Check network first, then cache
  const request = event.request
  event.respondWith(
    fetch(request)
      .then(response => addToCache(request, response))
      .catch(() => fetchFromCache(event))
      .catch(() => offlineResponse())
  )
}

The cool thing about Service Workers is, we are able to cache whatever we want at the start. There are size limits but we can be smart as developers and prioritise what is most important first.

Going offline

After the Service Worker has been installed and the user is offline, we show a banner on top of the app to let people know that they are browsing offline. If we have the app in cache because they browsed to that page in the past, they still get the full experience.

App when Offline
App when Offline

We use offline and online events to detect changes in connectivity and display the banner when users go offline.

bindEvents () {
  window.addEventListener('offline', this.onOfflineStatus)
  window.addEventListener('online', this.onOnlineStatus)
}

onOfflineStatus () {
  this.showOfflineBanner()
}

onOnlineStatus () {
  this.hideOfflineBanner()
}

To show the banner if users access the app while offline, we add this extra check when loading the app.

if (navigator.onLine === false) {
  this.showOfflineBanner()
}

This app is simple, so we are able to cache all assets for the app on the first visit. In more complex situation you might not be able to cache everything. In those cases, it is good to show a special “Offline” page if users access content that is not cached, and gray out links that are not available offline.

Word about DevTools

There is a Service Worker view in Chrome DevTools under the “Application” tab.

Service Workers in Chrome DevTools
Service Workers in Chrome DevTools

This view is essential to test the app when Offline and to Bypass for Network to test the app without using any Service Worker.

To understand the Upload on reload checkbox, remember that Service Worker changes don’t take effect until the page has been closed and opened again on next visit. This check makes sure all changes takes effect on the next page refresh (shortcut: ⌘+R).

This is useful, but I found that sometimes I had to Unregister the service worker and load it again for changes to take affect. Hopefully this will improve soon.

Add to Home suggestion

A superpower of PWAs is that users get prompted to add the app to their home screen when used frequently. It is one of PWAs biggest selling points as it allows app-makers to bypass App Stores and simply share apps via links.

There are number of criterias that apps need to meet for “Add to Home Suggestion” to work properly. To see it in action, the following flag can be set in the Chrome address bar on the mobile device.

chrome://flags/#bypass-app-banner-engagement-checks

You have to Relaunch the browser after the flag has been enabled. If everything is correct, this prompt should show up:

Add to Home Suggestion
Add to Home Suggestion

The app will live on the user home screen onwards:

App on Android Home Screen
App on Android Home Screen

The app will open without any address bar when launched from the home screen:

App in Full Screen
App in Full Screen

We can only hope that Apple will add an “Add to Home Suggestion” to Safari on the iPhone soon, as this ability is currently only on Android devices.

Splash Screen, App Icons and More.

Time to add decorations to the mix using a Manifest file. The Manifest gives the browser information about the app and how to style it outside the viewport.

The Manifest is included in the <head> of the HTML page:

<link rel="manifest" href="/manifest.json">

This is our current manifest.json file:

{
  "manifest_version": 1,
  "version": "1.0.0",
  "name": "Vecka App",
  "short_name": "Vecka",
  "description": "När du vill veta vilken vecka det är.",
  "default_locale": "se",
  "icons": [
        {
            "src": "/icons/android-chrome-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/icons/android-chrome-256x256.png",
            "sizes": "256x256",
            "type": "image/png"
        }
    ],
  "start_url": "/",
  "display": "standalone",
  "background_color": "#121738",
  "theme_color": "#d17c78"
}

The name and background_color are combined to create a Splash Screen that will show instantly while the app is loading. This gives a snappy feeling when launcing the app.

App Splash-Screen on Android
App Splash-Screen on Android

The theme_color is used to style the browser bar in a fitting colour. Sweeet!

App browser bar with custom color
App browser bar with custom color

Additionally, app icons for Android should be included in the manifest file.

"icons": [
    {
        "src": "/icons/android-chrome-192x192.png",
        "sizes": "192x192",
        "type": "image/png"
    },
    {
        "src": "/icons/android-chrome-256x256.png",
        "sizes": "256x256",
        "type": "image/png"
    }
]

We still specify icons in the <head> of the HTML page for Apple Safari, the good old way.

<!-- icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/icons/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/icons/favicon-16x16.png" sizes="16x16">
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg" color="<%= themeColor %>">

To generate icons with code, we used the Real Favicon Generator. It provides the ability to customise the look of app icons for different browsers. This generator works really well for modern PWAs to speed up the process.

Lighthouse guides the ship

There are a lot of quality checks to do when building PWAs, here are some of them:

  • App can load on offline/flaky connections and page load performance is fast
  • App is built using Progressive Enhancement principles
  • App is using HTTPS for secure communications
  • Users will be prompted to Add to Home screen
  • Installed web app will launch with Custom Splash Screen and address bar will match brand colours

To confirm the above requirements are met, a tool called Lighthouse is the golden standard. Lighthouse is a Chrome extension that will analyze any site for Progressive Web App best practices. It shouldn’t be taken as gospel (even though we did), but it’s super helpful.

App browser bar with custom color

"Lighthouse analyzes web apps and web pages, collecting modern performance metrics and insights on developer best practices.”

Lighthouse is great but can be a bit frustrating sometimes as results vary depending on conditions. It’s early days of PWAs and the tools are bound to get better.

Let’s push the web

I think every website from now on should use some of the Progressive Web App features. It’s even confusing to call it “Apps” as it applies to all websites and apps.

Additionally, there are countless APIs available that fall under the PWA umbrella that enable us to push the web further. Push Notifications. Painless Web Payments. Access to the camera and other device capabilities. Access to surrounding devices via Web Bluetooth. Web Beacons to give contextual information. Access to VR capabilities. The list goes on and on and on.

Exciting times ahead for the web!

Write a comment

Keep me posted!