Bidirectional messaging between pages and service workers

Some web apps need more than one-way messaging between the page and its service worker. A podcast PWA, for instance, might let users download episodes for offline listening while the service worker periodically reports download progress back to the main thread so the UI can update.

Several techniques exist for this two-way communication, ranging from the convenience of Workbox's workbox-window module to lower-level browser APIs. The APIs share a common pattern: communication starts with postMessage() on one end and is handled with a message event listener on the other. They differ in how they identify the counterpart context and in browser support.

Workbox's wrapper: workbox-window

The workbox-window module provides a Workbox class with a messageSW() method that sends a message to the registered service worker and awaits a reply.

Page-side code creates a Workbox instance and sends a message requesting the service worker's version:

const wb = new Workbox('/sw.js');
wb.register();

const swVersion = await wb.messageSW({type: 'GET_VERSION'});
console.log('Service Worker version:', swVersion);

On the service worker side, a message listener responds:

const SW_VERSION = '1.0.0';

self.addEventListener('message', (event) => {
  if (event.data.type === 'GET_VERSION') {
    event.ports[0].postMessage(SW_VERSION);
  }
});

Workbox implements this on top of the Message Channel API, which has broad browser support. The library abstracts away lower-level details, making the API easier to use.

Direct browser APIs

When Workbox isn't suitable, there are several lower-level options. Each uses postMessage() and a message handler, and each can handle the same scenarios, though some simplify specific cases. They differ in how they reference the other side and in browser compatibility.

Broadcast Channel API

The Broadcast Channel API enables communication between browsing contexts via BroadcastChannel objects. Each context instantiates a BroadcastChannel with the same identifier:

const broadcast = new BroadcastChannel('channel-123');

Messages go out via the channel's postMessage():

//send message
broadcast.postMessage({ type: 'MSG_ID', });

Any listening context receives them with the onmessage handler:

//listen to messages
broadcast.onmessage = (event) => {
  if (event.data && event.data.type === 'MSG_ID') {
    //process message...
  }
};

There's no explicit reference to a particular context, so no prior lookup of the service worker or a client is needed. The trade-off is support: Chrome, Firefox, and Edge handle it, but Safari does not yet.

Client API

The Client API lets a service worker get a reference to all the WindowClient objects for the tabs it controls. Because a page is controlled by a single service worker, it can send messages directly through the serviceWorker interface:

//send message
navigator.serviceWorker.controller.postMessage({
  type: 'MSG_ID',
});

//listen to messages
navigator.serviceWorker.onmessage = (event) => {
  if (event.data && event.data.type === 'MSG_ID') {
    //process response
  }
};

The service worker listens with its own onmessage handler:

//listen to messages
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'MSG_ID') {
    //Process message
  }
});

To reply, the service worker retrieves its clients via methods like Clients.matchAll() or Clients.get() and posts a message to one of them:

//Obtain an array of Window client objects
self.clients.matchAll(options).then(function (clients) {
  if (clients && clients.length) {
    //Respond to last focused tab
    clients[0].postMessage({type: 'MSG_ID'});
  }
});

The Client API is a straightforward way to reach all active tabs from a service worker. All major browsers support it, but not every method is universally available—check compatibility before relying on a specific one.

Message Channel

Message Channel requires explicitly passing a port from one context to the other. The page creates a MessageChannel, sends one of its ports to the registered service worker, and sets up an onmessage listener on the port:

const messageChannel = new MessageChannel();

//Init port
navigator.serviceWorker.controller.postMessage({type: 'PORT_INITIALIZATION'}, [
  messageChannel.port2,
]);

//Listen to messages
messageChannel.port1.onmessage = (event) => {
  // Process message
};

The service worker saves a reference to the received port and uses it to send messages back:

let communicationPort;

//Save reference to port
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'PORT_INITIALIZATION') {
    communicationPort = event.ports[0];
  }
});

//Send messages
communicationPort.postMessage({type: 'MSG_ID'});

Message Channel is supported by all major browsers.

Specialized APIs for connectivity and long downloads

The patterns above suit short exchanges, like a string message or a list of URLs to cache. Two dedicated APIs handle scenarios the others can't: flaky connectivity and long-running tasks that could outlive the service worker.

Background Sync

A chat app needs to guarantee that messages aren't lost when connectivity drops. The Background Sync API defers an action and retries it once the user has a stable connection. Instead of calling postMessage(), the page registers a sync:

navigator.serviceWorker.ready.then(function (swRegistration) {
  return swRegistration.sync.register('myFirstSync');
});

The service worker listens for the sync event to do the work:

self.addEventListener('sync', function (event) {
  if (event.tag == 'myFirstSync') {
    event.waitUntil(doSomeStuff());
  }
});

The doSomeStuff() function returns a promise. If it resolves, the sync finishes; if it rejects, another sync is scheduled with exponential backoff after connectivity returns. Once the task completes, the service worker can notify the page via any of the messaging APIs covered above. Google Search uses this pattern to persist failed queries and notify the user through a web push once the retry succeeds.

Background Fetch

For longer tasks like downloading a movie or a podcast, the browser may kill the service worker—a safeguard for the user's battery and privacy. The Background Fetch API offloads such work to the service worker. Instead of postMessage(), the page calls backgroundFetch.fetch:

navigator.serviceWorker.ready.then(async (swReg) => {
  const bgFetch = await swReg.backgroundFetch.fetch(
    'my-fetch',
    ['/ep-5.mp3', 'ep-5-artwork.jpg'],
    {
      title: 'Episode 5: Interesting things.',
      icons: [
        {
          sizes: '300x300',
          src: '/ep-5-icon.png',
          type: 'image/png',
        },
      ],
      downloadTotal: 60 * 1024 * 1024,
    },
  );
});

The resulting BackgroundFetchRegistration exposes a progress event the page can listen to for download updates:

bgFetch.addEventListener('progress', () => {
  // If we didn't provide a total, we can't provide a %.
  if (!bgFetch.downloadTotal) return;

  const percent = Math.round(
    (bgFetch.downloaded / bgFetch.downloadTotal) * 100,
  );
  console.log(`Download progress: ${percent}%`);
});

These options cover bidirectional messaging for most practical cases. When only one direction is needed, such as imperative caching from the page or pushing a new-version notice from the service worker, the unidirectional techniques are simpler alternatives. The imperative caching guide covers asking the service worker to cache resources ahead of time, while the broadcast updates guide explains how the service worker can inform the page of important events.