Building Progressive Web Apps
By Diogo Cunha

We love creating fast and engaging web apps at nearForm. It’s part of our DNA to take whatever we do to the next level. Even though we are known for caring about the performance of what we build, we are also known for caring about the end-user experience.

New web standards are coming out often, it’s a constantly changing world with new technologies and frameworks. We like to lead the way on what we do best: rock-solid, blazing-fast and all-around awesome software that incorporates the bleeding edge without compromising compatibility, keeping existing audiences in mind. Progressive Web Apps (PWA) gives us the perfect opportunity to do just that.

In this article we detail how to build a progressive web app covering the following topics:

  • What is a PWA.
  • What makes a PWA progressive.
  • The core principles of a PWA: Fast, Reliable and Engaging.
  • What is an App Shell and how can it help us deliver a great user experience.

What is a Progressive Web App?

A progressive web app is an enhanced version of a Single Page App (SPA) with a native app feel. Progressive web apps are developed using web standards, but they have some native features because they are fast, smooth and responsive. They are installable on the device, they work regardless of the network state and they engage with a user just like a native-app does.

Progressive Enhancement

The P in PWA stands for progressive as in progressive enhancement1. This is when a web app provides the best experience possible within the available capabilities of the browser being used; ultimately taking advantage of cutting-edge features while still providing an experience to the user without relying on JavaScript.

Server-side rendering

To achieve the ultimate level of progressive enhancement we need a web app that can fully render content on the server, not just a header and footer for example. You can argue that no one is using a browser without JavaScript, thought this can be true, in fact, we can see the browser as “JavaScript-less” while initially loading a page. Without this “JavaScript-less” experience the user won’t see any content until a page is fully loaded, neither can search engine bots because most of them don’t run JavaScript.

Besides enabling progressive enhancement, server-side rendering also brings performance and SEO benefits. By making sure the first step is done right and our content is rendered on the server, we have a solid baseline for creating the best experience possible.

Fast - Performance matters

Performance matters the most the first time your user visits. Local caches are empty and resources have to be downloaded from the server through the network. Depending on the network and device being used, there is a period of time where the user has to wait until something is displayed on screen. On high-latency mobile networks like 3G, that period can go up to dozens of seconds if no optimizations are in place.

The amount of time the user has to wait is critical. The user’s attention span decreases as the waiting time increases, ultimately forcing the user to leave after a certain threshold2. This has a huge impact on a business. Studies show that the conversion rates are much higher on web apps that load faster when compared to their slower counterparts, concluding that 1 extra second of page loading can be worth millions in lost revenue!3

In order to avoid this scenario the load time must be optimized. Looking at a simplified way of how the browser handles the initial page load, some prioritization can be put in place to optimize the critical render path. This helps deliver meaningful content as fast as possible creating the perception that the page is loaded, even if the loading is still ongoing.

Simplified way of how the browser handles the initial page load

It becomes obvious that we need to remove JavaScript and CSS files from the critical render path by making them non render-blocking. These resources are still download concurrently but only loaded after the first render finishes. It’s important to remember that our goal is to deliver content as fast as possible so we don’t rely on JavaScript to fetch and display the content. Server-side rendering must be used to ensure content is displayed at the first render.

Some optimizations that can be used to improve the perceived load time are:

Improve HTML download time

  • Reduce HTML size (probably not much to reduce)
  • Reduce TTFB4 as much as possible: Use CDN / caching for static pages and improve server page rendering performance

Improve other resources download times. For example JS, CSS and so on..

  • Reduce JS and CSS size by splitting code into separate bundles eg: homepage.js and homepage.css, about_us.js and about_us.css
  • Serve these resources fast, ideally using a CDN / caching
  • Use HTTP2 push to send those files reducing overall RTT5

Improve time for render-blocking resources to load

  • Load CSS files after first render (using JavaScript)
  • Inline styles above the fold (otherwise page will be unstyled)
  • Use defer or async on script tags <script src=”...” defer>

Improve time for non render-blocking resources to load

  • Lazy-load images under the fold and crop them accordingly to their render size
  • Lazy load other components and parts that are under the fold
  • Serve these resources fast, ideally using a CDN / caching

Good practice when developing a web app is to set a performance budget. Just like a financial budget you set how much you are willing to spend on each performance metric. Metrics such as bundle sizes, time to first render, time to interactive, perceptual speed index and so on. There are tools to help you measure these metrics and tell you if you’re on budget or not, for example, Lighthouse6, WebPagetest7 and Chrome DevTools

Despite our focus on load performance, the overall perceived performance of a web app after load is still relevant. It’s long been accepted that users can maintain their sense of “flow” when the response times are between 0.1 and 1 second. Anything longer than that and the user’s attention diminishes8.

Reliability - Just works every time

The reliability aspect of a PWA is fundamental because users must have a fast, instant loading experience every time, regardless of the network conditions. It may happen that the user is offline, the network is really slow or simply in a lie-fi9 situation, where the connection is clearly not what it seems to be. When this happens we turn to “service workers”. They help us to deliver a reliable experience regardless of connection issues.

Service Workers

A service worker is a piece of JavaScript that is installed in the browser during a page visit and runs in the background for as long as the page is open. On subsequent visits, the service worker is installed and can perform a variety of functions. A very interesting capability is the interception of network requests; this can be used for caching purposes but ultimately you can implement your “server” as a service worker. Caching is fundamental because it allows the app to respond immediately to the user when it comes to loading resources or data, regardless of the network conditions, including offline.

Caching Resources and Strategies

The first obvious choice of which resources to cache are JavaScript and CSS. HTML can also be cached and we look into that when we cover the App Shell model. When it comes to caching these resources there are 2 types of strategies that can be used:

A pre-caching strategy can be used so that the service worker preemptively fetches and cache all the JavaScript, CSS, and essential elements likes images, fonts and the app shell, as we will see in the app shell section. This is done while the browser is idle to ensure it’s not getting in the way of other important things. Pre-caching allows the experience to be fast for every page of your web app are visited, regardless of the network conditions. One can see this as installing the app on the device, since it now lives there.

Runtime caching for other dynamic resources such as API responses and images. This ensures fast loading times for recurrent data but also enables offline access to it. There are a few runtime caching strategies such as: cache first, network first or stale while revalidate (returns cached version first and then updates cache from the network). Pre-caching can also be used for these types of resources but usually runtime caching strategies are most suited.

These strategies can be implemented manually but there are tools like Workbox that can help you to manage them.

Create a Service Worker

Workbox10 is a tool developed by Google that helps the creation of service workers It provides a set of libraries, Node.js modules, command-line tools and plugins for build tools. Workbox can be used as a library for custom-made service workers but can also generate code from a configuration file and can easily be integrated into your build process.

An example of a configuration file and the generated service worker is:

{
  swDest: 'public/sw.js',    // Generated service worker file destination
  globDirectory: 'public/',  // Directory to find assets to pre-cache
  globPatterns: [
    'assets/*.js',
    'assets/*.css'
  ]
  navigateFallback: '/app-shell', // resource to deliver for any URL (app shell section)
  runtimeCaching: [{
    urlPattern: '/images',
    handler: 'cacheFirst',   // Strategy to use
    options: {
      cacheName: 'images',
      cacheExpiration: {
        maxEntries: 30       // Cache expiration size
      }
    }
  }, {
    urlPattern: '/api/*',
    handler: 'staleWhileRevalidate',
    options: {
      cacheName: 'api',
      cacheExpiration: {
        maxEntries: 50,
        maxAgeSeconds: 60 * 60 * 24 // 1 day
      }
    }
  }]
}
importScripts("workbox-sw.prod.v2.1.3.js");

const fileManifest = [
  {
    url: "assets/article.js",
    revision: "c65f0ed7d1f3a584b4d0cff791900fb7"
  },
  {
    url: "assets/home.js",
    revision: "c90ed9ad4f76b1166441a7a1c1cf6b19"
  },
  {
    url: "assets/common.js",
    revision: "8127580c351d6d5c892b8d77982b5355"
  },
  {
    url: "assets/article.css",
    revision: "d41d8cd98f00b204e9800998ecf8427e"
  },
  {
    url: "assets/home.css",
    revision: "d41d8cd98f00b204e9800998ecf8427e"
  },
  {
    url: "assets/common.css",
    revision: "f657bc18fc6ec87e05d6f96d2de945ba"
  }
];

const workboxSW = new self.WorkboxSW();
workboxSW.precache(fileManifest);
workboxSW.router.registerNavigationRoute("/app-shell");
workboxSW.router.registerRoute(
  "/images",
  workboxSW.strategies.cacheFirst({
    cacheName: "images",
    cacheExpiration: {
      maxEntries: 30
    }
  }),
  "GET"
);
workboxSW.router.registerRoute(
  "/api/*",
  workboxSW.strategies.staleWhileRevalidate({
    cacheName: "api",
    cacheExpiration: {
      maxEntries: 50,
      maxAgeSeconds: 86400
    }
  }),
  "GET"
);

Offline access

Speed isn’t the only reason for caching resources. Service workers are decoupled from the web app itself, they act as a proxy between the web app and the network therefore they can provide content from a cache while offline. This gives the opportunity to develop and deliver an offline experience. Ideally the user won’t even realise the network is gone. The ultimate measure of reliability of a PWA is to never display the infamous downasaur.

Engaging - Hold your audience’s attention

New browser APIs enable a user-experience that is much closer to a native experience. Standards like the web app manifest or the web push notification help to enable the level of engagement with the user.

If a web app manifest file is provided, the user has the option to install and launch the web app from the home screen. An immersive full-screen experience, together with a loading page can be provided, without location bars or other browser related elements. Push notifications can be sent to the device, driving the user to the web app and increasing the levels of engagement. Permission to send push notifications have to be accepted by the user through a native pop-up interface beforehand.

{
  "name": "Our PWA",
  "short_name": "OurPWA",
  "description": "A PWA showcase",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#000",
  "theme_color": "#000",
  "icons": [{
    "src": "/assets/icon.png",
    "type": "image/png",
    "sizes": "512x512"
  }]
}

Example of a Web App Manifest file

It’s important to have a sense of opportunity when requesting such permissions. If the user decides to deny this permission once, the push notifications are blocked forever and there is no second chance to request them. Rather than requesting it immediately at the first visit, doing it after some level of interaction with the user might increase the chance of acceptance. For example, the user might find it more relevant to receive push notifications after subscribing to a topic on your blog.

The App Shell

One of the core concepts of a PWA is speed, it has to be fast. We want the user to get something immediately from the moment a page is revisited. Once the service worker is installed, we can proxy every single network request we want, so why not deliver something straight away instead of waiting for the server to respond? Well, we can and that is one of the basic concepts of an app shell.

An app shell is a container that is capable of loading any parts or pages of our web app. It’s the common denominator between all the pages. It usually includes headers, footers, navigation bars, other common graphical components and a router that can display the corresponding page based on the URL that is being visited.

Whereas before we might render every page for a given URL on the server, now with a service worker installed and running, we immediately deliver the app shell for any URL that is visited and let the app shell load the corresponding page.

This give us an opportunity to show something to the user from the first moment the web app is visited. If caches are populated we deliver content straight away. If the data is not in cache or if the required JavaScript or CSS has yet to be loaded, we show a spinner or progress bar while the loading happens in the background.

This makes the app shell an obvious contender to include in the pre-cache strategy. It’s important to keep in mind that the app shell must be as minimal as possible; that is the only way to ensure that loading time is insignificant. Otherwise, a big, chunky app shell will take some time to load, even if it is cached locally, defeating the purpose of instant delivery.

The app shell can be server-side rendered or it can be a static asset. Since we decided to go on a full server-side rendered approach, it makes sense to have the app shell being server-side rendered.

For a in depth read on what an app shell is we recommend the article by Google in the footer11.

Conclusion

Progressive web apps are awesome and an incredible achievement of the web standards. The first envisionment of mobile apps were based on web technologies, unfortunately at that time the technology was underdeveloped and that made companies and developers adopt a native platform model. Things have changed; the web has evolved and now we have one single platform that is feature-rich and runs everywhere. One might easily conclude that PWAs make native apps almost obsolete! If you decide to give PWAs a try and need any help building it get in touch with us at nearForm.

As a companion to this article, we have written two reference Hacker News clones to showcase PWAs and everything we discuss above.

Other links:

New Call-to-action
join the discussion