Why Bulletin went with a PWA

The Google Bulletin team built an external-facing PWA between mid-2017 and mid-2019. The project’s constraints made a PWA an attractive choice for several reasons:

  • Rapid iteration. Bulletin was slated for pilot testing in multiple markets, so the team needed to ship changes quickly.
  • One code base for two platforms. With users split roughly evenly between Android and iOS, a single web app avoided building and maintaining two native apps.
  • Automatic updates. PWAs update themselves without requiring users to take action, reducing the number of out-of-date clients and cutting migration time when breaking backend changes shipped.
  • Easy integration. Connecting with first- and third-party apps often meant just opening a URL.
  • No install friction. Users didn’t have to go through an app store to get started.

The team used Polymer as its framework, though any modern, well-supported framework would work.

Douglas Parker

This is the first in a series of posts sharing lessons from Bulletin’s development. This installment focuses on what the team learned about service workers.

Service worker lessons

A service worker is essential to a PWA, and it brings power: offline capabilities, background sync, and advanced caching. That power comes with complexity, but the trade-off was worth it. Below are the problems the team hit and how it worked around them.

Generate your service worker if possible

Hand-writing a service worker means manually managing cached resources and re-implementing logic common to libraries like Workbox. The Bulletin team’s internal tech stack prevented it from using a generator, and some of the learnings below reflect that. If you can use a library, do so.

Check libraries for service worker compatibility

Many JavaScript libraries assume APIs that don’t exist in a service worker context, such as window, document, XMLHttpRequest, or local storage. The team wanted to use gapi.js for authentication but couldn’t because it didn’t support service workers. Before depending on a critical library, verify it works in a service worker environment. Library authors should also minimize assumptions about the JavaScript context — avoid service-worker-incompatible APIs and global state.

Don’t touch IndexedDB during initialization

The team learned the hard way that reading IndexedDB (IDB) while a service worker script initializes can deadlock the install. The failure sequence looks like this:

  1. A user has the web app with IDB version N.
  2. A new version of the app ships with IDB version N+1.
  3. The user visits the PWA, which triggers a new service worker download.
  4. Before registering an install event handler, the new service worker reads from IDB, triggering an upgrade cycle from N to N+1.
  5. The old client still holds an open connection to version N, so the upgrade hangs.
  6. The service worker hangs and never installs.

Because Bulletin invalidated its cache on install, a stuck install meant users never got the updated app.

Make long-running processes resumable

Service workers can be terminated at any time, even mid-I/O (network, IDB, or otherwise). The team’s sync process uploaded large files to the server and saved to IDB. For interrupted partial uploads, the solution leveraged the upload library’s resumable system: save the resumable upload URL to IDB before uploading, then resume from that URL if the upload didn’t complete. More generally, state was written to IDB before any long-running I/O operation to record where in the process each record stood.

Avoid relying on global state

Code that runs in both window and service worker contexts can’t assume services like local storage or cookies exist. globalThis provides a way to reference the global object across contexts. Also, keep stored data in global variables minimal — a service worker can be terminated at any time, evicting that state.

Handle local development with care

Service workers cache resources locally, which is the opposite of what you want during development when changes are frequent and updates are lazy. But you still want the service worker installed to debug it and test APIs like background sync or notifications. In Chrome DevTools, enable Bypass for network in the Application > Service workers pane and Disable cache in the Network panel. Bulletin covered more browsers by including a flag in its service worker that disabled caching by default in developer builds. Also include the Cache-Control: no-cache header to prevent the browser from caching unversioned assets.

Add Lighthouse to CI

Lighthouse scans a site and reports on PWA criteria, performance, accessibility, SEO, and more. The team recommends running it on continuous integration. That actually would have caught the team’s own bug once: a production push went out with a service worker that wasn’t installing, and nobody noticed until after the push.

Take advantage of continuous delivery

Because service workers update automatically, users can’t delay upgrades the way they can with native apps. When a user opened Bulletin, the service worker served the old client from cache while lazily downloading the new one. After the download completed, the user got a prompt to refresh for new features; if they ignored it, the next refresh brought the new version anyway. Breaking backend changes could ship with only about a month of migration time.

One edge case: if a user hasn’t opened an app in a long time, an older client can persist via serve-while-stale. On iOS, service workers are evicted after a couple weeks, so this isn’t a problem. On Android you can avoid it by refusing to serve stale content or by expiring content after a few weeks. The team never hit problems in practice, and teams can tune strictness to their use case.

Service workers can’t use synchronous cookie APIs like document.cookies. Bulletin needed cookie values to generate tokens for first-party API requests. One workaround is messaging active windowed clients to ask for cookie values, but that fails when the service worker runs with no windowed clients (for example, during background sync). Instead, the team created an endpoint on its frontend server that echoed the cookie value, and the service worker fetched it over the network. The Cookie Store API should eliminate this workaround in browsers that support it, since it provides asynchronous cookie access directly from a service worker.

Pitfalls when you can’t generate a service worker

Keep the script fingerprint in sync with cached files

A typical PWA pattern installs all static files during the install phase so clients can hit the cache directly afterward. But browsers only install a service worker when its script changes. Since cached files changed without changing the script, the team manually embedded a hash of the static resource set into the service worker script, ensuring each release produced a unique service worker JavaScript file. Libraries like Workbox handle this automatically.

Make unit testing manageable

Service worker APIs register event listeners on the global object:

self.addEventListener('fetch', (evt) => evt.respondWith(fetch('/foo')));

Testing that means mocking the event trigger and object, waiting on the respondWith() callback, and awaiting the promise before asserting. It’s easier to move implementation into a separate, testable module:

import fetchHandler from './fetch_handler.js';
self.addEventListener('fetch', (evt) => evt.respondWith(fetchHandler(evt)));

The team kept its core service worker script as thin as possible, pushing most logic into standard JS modules that unit-test with ordinary testing libraries.