Two-way communication with service workers

Andrew Guan
Andrew Guan
Demián Renzulli
Demián Renzulli

In some cases, a web app might need to establish a two-way communication channel between the page and the service worker.

For example: in a podcast PWA one could build a feature to let the user download episodes for offline consumption and allow the service worker to keep the page regularly informed about the progress, so the main thread can update the UI.

In this guide we'll explore the different ways of implementing a two-way communication between the Window and service worker context, by exploring different APIs, the Workbox library, as well as some advanced cases.

Diagram showing a service worker and the page exchanging messages.

Using Workbox

workbox-window is a set of modules of the Workbox library that are intended to run in the window context. The Workbox class provides a messageSW() method to send a message to the instance's registered service worker and await a response.

The following page code creates a new Workbox instance and sends a message to the service worker to obtain its version:

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

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

The service worker implements a message listener on the other end, and responds to the registered service worker:

const SW_VERSION = '1.0.0';

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

Under the hood the library uses a browser API that we'll review in the next section: Message Channel, but abstracts many implementation details, making it easier to use, while leveraging the wide browser support this API has.

Diagram showing two-way communication between page and service worker, using Workbox Window.

Using Browser APIs

If the Workbox library is not enough for your needs, there are several lower-level APIs available to implement "two-way" communication between pages and service workers. They have some similarities and differences:

Similarities:

  • In all cases the communication starts on one end via the postMessage() interface and is received on the other end by implementing a message handler.
  • In practice, all the available APIs allow us to implement the same use cases, but some of them might simplify development in some scenarios.

Differences:

  • They have different ways of identifying the other side of the communication: some of them use an explicit reference to the other context, while others can communicate implicitly via a proxy object instantiated on each side.
  • Browser support varies among them.
Diagram showing two-way communication between page and service worker, and the available browser APIs.

Broadcast Channel API

Browser Support

  • Chrome: 54.
  • Edge: 79.
  • Firefox: 38.
  • Safari: 15.4.

Source

The Broadcast Channel API allows basic communication between browsing contexts via BroadcastChannel objects.

To implement it, first, each context has to instantiate a BroadcastChannel object with the same ID and send and receive messages from it:

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

The BroadcastChannel object exposes a postMessage() interface to send a message to any listening context:

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

Any browser context can listen to messages via the onmessage method of the BroadcastChannel object:

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

As seen, there's no explicit reference to a particular context, so there's no need of obtaining a reference first to the service worker or any particular client.

Diagram showing two-way communication between page and service worker, using a Broadcast Channel object.

The disadvantage is that, at the moment of this writing, the API has support from Chrome, Firefox and Edge, but other browsers, like Safari, don't support it yet.

Client API

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

The Client API allows you to obtain a reference to all the WindowClient objects representing the active tabs that the service worker is controlling.

Since the page is controlled by a single service worker, it listens to and sends messages to the active service worker directly via 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
 
}
};

Similarly, the service worker listens to messages by implementing an onmessage listener:

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

To communicate back with any of its clients, the service worker obtains an array of WindowClient objects by executing methods such as Clients.matchAll() and Clients.get(). Then it can postMessage() any 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'});
 
}
});
Diagram showing a service worker communicating with an array of clients.

Client API is a good option to communicate easily with all the active tabs from a service worker in a relatively straightforward way. The API is supported by all major browsers, but not all its methods might be available, so make sure to check browser support before implementing it in your site.

Message Channel

Browser Support

  • Chrome: 2.
  • Edge: 12.
  • Firefox: 41.
  • Safari: 5.

Source

Message Channel requires defining and passing a port from one context to another to establish a two-way communication channel.

To initialize the channel, the page instantiates a MessageChannel object and uses it to send a port to the registered service worker. The page also implements an onmessage listener on it to receive messages from the other context:

const messageChannel = new MessageChannel();

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

//Listen to messages
messageChannel
.port1.onmessage = (event) => {
 
// Process message
};
Diagram showing a page passing a port to a service worker, to establish two-way communication.

The service worker receives the port, saves a reference to it and uses it to send a message to the other side:

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'});

MessageChannel is currently supported by all major browsers.

Advanced APIs: Background Sync and Background Fetch

In this guide we explored ways of implementing two-way communication techniques, for relatively simple cases, like passing a string message describing the operation to perform, or a list of URLs to cache from one context to the other. In this section we'll explore two APIs to handle specific scenarios: lack of connectivity and long downloads.

Background Sync

Browser Support

  • Chrome: 49.
  • Edge: 79.
  • Firefox: not supported.
  • Safari: not supported.

Source

A chat app might want to make sure that messages are never lost due to bad connectivity. The Background Sync API lets you defer actions to be retried when the user has stable connectivity. This is useful for ensuring that whatever the user wants to send, is actually sent.

Instead of the postMessage() interface, the page registers a sync:

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

The service worker then listens for the sync event to process the message:

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

The function doSomeStuff() should return a promise indicating the success/failure of whatever it's trying to do. If it fulfills, the sync is complete. If it fails, another sync will be scheduled to retry. Retry syncs also wait for connectivity, and employ an exponential back-off.

Once the operation has been performed, the service worker can then communicate back with the page to update the UI, by using any of the communication APIs explored earlier.

Google search uses Background Sync to persist failed queries due to bad connectivity, and retry them later when the user is online. Once the operation is performed, they communicate the result to the user via a web push notification:

Diagram showing a page passing a port to a service worker, to establish two-way communication.

Background Fetch

Browser Support

  • Chrome: 74.
  • Edge: 79.
  • Firefox: not supported.
  • Safari: not supported.

Source

For relatively short bits of work like sending a message, or a list of URLs to cache, the options explored so far are a good choice. If the task takes too long the browser will kill the service worker, otherwise it's a risk to the user's privacy and battery.

The Background Fetch API allows you to offload a long task to a service worker, like downloading movies, podcasts, or levels of a game.

To communicate to the service worker from the page, use backgroundFetch.fetch, instead of postMessage():

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 BackgroundFetchRegistration object allows the page listen to the progress event to follow the progress of the download:

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}%`);
});
Diagram showing a page passing a port to a service worker, to establish two-way communication.
The UI is updated to indicate the progress of a download (left). Thanks to service workers, the operation can continue running when all tabs have been closed (right).

Next steps

In this guide we explored the most general case of communication between page and service workers (bidirectional communication).

Many times, one might need only one context to communicate with the other, without receiving a response. Check out the following guides for guidance how to implement unidirectional techniques in your pages from and to the service worker, along with use cases and production examples:

  • Imperative caching guide: Calling a service worker from the page to cache resources in advance (e.g. in prefetching scenarios).
  • Broadcast updates: Calling the page from the service worker to inform about important updates (e.g. a new version of the webapp is available).