Matthew Tyson
Contributing writer

A first look at progressive web apps

how-to
22 May 20248 mins
JavaScriptSoftware DevelopmentWeb Development

Progressive web apps are more complex to develop than traditional web applications, but they pack a lot of punch in return.

Dog on a surfboard wearing sunglasses.
Credit: Javier Brosch/Shutterstock

Progressive web apps are an innovation of modern web development, pairing the ubiquity of web browsers with the richness of native applications. Specialized features such as service workers increase the complexity of development as compared to a typical web UI, but they provide an enormous benefit in exchange: cross-device, native-like features delivered inside a web browser.

Features of progressive web apps

If you consider the difference between a typical web browser application and an app installed on a laptop or mobile phone, you get a sense of the gap that progressive web apps bridge. A defining feature of installed apps is that they run on the device when there is no network connection. Progressive web apps support similar offline functionality within the browser.

Unlike browser-based web applications, progressive web apps are highly dependent on application type: the application’s features play a big role in determining how the PWA is implemented.

Common characteristics of progressive web apps are:

  • Offline functionality
  • Background functionality, including syncing
  • Homepage “installation”
  • Push notifications and alerts, including when the app is not running
  • Aggressive caching (a strategy to combat intermittent network problems)
  • Cross-device/responsive/mobile-first layouts
  • Can be bookmarked and shared via a link

A good use case for a progressive web app is transitioning a web-deployed application to implement PWA features such as offline functionality. Google Docs is an example of a browser-based app that supports “offline mode” when the network is not available. Essentially, the app can save everything locally on the browser and then sync that up with the back end when the browser comes back online.

Of course, synchronizing distributed parts of an app is inherently complicated. Google Docs is a major architectural undertaking, but a more basic application could be a lot simpler. The application’s requirements will dictate how involved the PWA implementation must be.

Now that you have a sense of what progressive web apps can do for you, let’s look at how they work.

Installing progressive web apps

A distinctive feature of progressive web apps is they can be “installed” even though they run in the browser. On the front end, you put a link on the device homepage which launches the website in the browser.

Installation is done via a manifest.json file, which describes to the browser the app’s features and its homepage icon. Here’s an example of a simple manifest:


{
  "name": "My PWA App",
  "short_name": "My PWA",
  "icons": [
       {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#ffffff"
}

If the browser finds such a file in the root directory, and the file is valid, it will offer to add a link to the homepage.

Service workers

Service workers are the main avenue for delivering PWA features. The navigator.serviceWorker object gives you access to the Service Worker API. It is only available in a secure (HTTPS) context. A service worker is similar to a worker thread, but it has a more long-lived lifecycle and less access to the DOM and browser APIs.

Think of a service worker as an isolated context that can interact with your main thread (and other workers) via messages and events. It can respond to these events, run network requests, respond to push calls, and store things with the Cache API or with IndexedDB. A service worker can only act on the UI via messages back to the main thread. In a sense, a service worker is proxy middleware that runs in the browser. 

Service workers have a unique lifecycle. Specific conditions determine when they will be terminated. They can run and present notifications to the user based on push updates even after the page that spawned them is closed. The service worker lifecycle offers a good overview. You can also find browser-specific information about the termination of service workers; for example, in Chrome.

Service worker events

Essentially, service workers are asynchronous event handlers. They respond to events from the UI or from the back end. When building them, you have to plan for their context to be wiped away between events—you cannot save state in local variables to share between events; instead, you use the cache or a database. Here are all the events a service worker can respond to:

  • install: This event fires once when the service worker is first installed. It’s often used to pre-cache assets like HTML, CSS, and JavaScript files to enable offline functioning.
  • activate: This event fires after a service worker becomes active. This is a good place to clean up caches from previous versions of the service worker or perform any tasks that need to be done when the service worker takes control of clients (controlled web pages).
  • fetch: This event fires whenever a controlled page makes a request to the network. This allows the service worker to act as an invisible intermediary for the main page, intercepting requests and potentially modifying them to serve a cached version or handle it entirely.
  • sync: This event fires at browser-defined intervals when the network connection is stable. It’s often used for synchronizing data changes made offline with the server. The Sync API helps automate trying requests when the network is available.
  • push: This event fires when the service worker receives a push notification from a server. It allows the service worker to handle and display the notification to the user even if the webpage is closed.
  • notificationclick: This event fires when the user clicks on a displayed push notification. You can use this event to handle the notification interaction and potentially navigate the user to a specific page within your PWA.
  • error: This event can fire in various situations where the service worker encounters an error during its operation. You can use this event for logging or debugging purposes.
  • broadcast and post messages: These are events specifically raised by your JavaScript code in the main thread. They are used to pass data to your service workers.

Along with these events, service workers have access to several APIs:

  • IndexedDB: A robust object store database that supports querying. It lives between service worker instances and is shared with the main thread. 
  • Cache API: The Cache API makes it easy to take request objects and store their response. Combined with the fetch event, the Cache API makes it easy to transparently (from the view of the main thread) cache responses for offline mode. MDN has a good description of potential cache strategies.
  • Fetch and WebSocket APIs: Although the service worker does not have access to the DOM, it has full access to the network via the Fetch and WebSocket APIs.
  • Geolocation: There is an ongoing discussion about how to expose service workers to geolocation and support geolocation in service workers.

Service worker example

A service worker always begins life by being loaded in a JavaScript file with the navigator.serviceWorker object, like so:


const subscription = await navigator.serviceWorker.register('service-worker.js');

Event subscriptions then happen in service-worker.js. For example, to watch for a fetch event and use the cache API, you could do something like this:


self.addEventListener('fetch', (event) => {
  const request = event.request;
  const url = new URL(request.url);

  // Try serving assets from cache first
  event.respondWith(
    caches.match(request)
      .then((cachedResponse) => {
        // If found in cache, return the cached response
        if (cachedResponse) {
          return cachedResponse;
        }

        // If not in cache, fetch from network
        return fetch(request)
          .then((response) => {
            // Clone the response for potential caching
            const responseClone = response.clone();

            // Cache the new response for future requests
            caches.open('my-cache')
              .then((cache) => {
                cache.put(request, responseClone);
              });

            return response;
          });
      })
  );
});

When the service worker is loaded, self will refer to it. The addEventListener method lets you watch for various events. Inside the fetch event, you can use the Cache API to check if you have the given request URL already cached; if so, you will send that back. If the URL is new, then you make your request to the server and then cache the response. Notice the Cache API eliminates much of the complexity in determining what is or is not the same request. Using the service worker makes all this transparent to the main thread.

Conclusion

Progressive web apps let you deliver your app in a browser and offer capabilities you won’t find in a typical browser-based application. On the other hand, you have to deal with more complexity when developing progressive web apps. Most of the native-like things that you can do with a progressive web app involve service workers, which require more work than doing the same thing in a native app using an operating system such as Android or macOS.

Using PWA techniques to achieve the same functionality across multiple target platforms inside the browser is less work than reimplementing the functionality on multiple platforms. With a progressive web app, you need only build and maintain a single codebase, and you get to work with familiar browser standards.

Exit mobile version