How to use async/await with postMessage

Use MessageChannel and error propagation to use async/await with cross-context communication

Author's image
Tamás Sallai
7 mins

Cross-context communication with postMessage

The postMessage call allows an asynchronous communication channel between different browsing contexts, such as with IFrames and web workers, where direct function calls don't work. It works by sending a message to the other side, then the receiving end can listen to message events.

For example, the page can communicate with an IFrame via postMessage and send events to each others' windows. The iframe.contentWindow.postMessage() call sends a message to the IFrame, while the window.parent.postMessage() sends a message back to the main page. The two ends can listen to messages from the other using window.addEventListener("message", (event) => {...}).

// index.html
const iframe = document.querySelector("iframe");

window.addEventListener("message", ({data}) => {
	console.log("Message from iframe: " + data); // 3: receive response
});

iframe.contentWindow.postMessage([5, 2]); // 1: send request

// iframe.html
window.addEventListener("message", ({data}) => {
	window.parent.postMessage(event.data[0] + event.data[1]); // 2: send response
});

For web workers, each worker has a separate message handler. The page can send a message to a specific worker using the worker.postMessage() call and listen for events from that worker using worker.addEventListener("message", (event) => {...}). On the other side, the worker sends and receives events using the global functions postMessage() and addEventListener():

// index.html
const worker = new Worker("worker.js");
worker.addEventListener("message", ({data}) => {
	console.log("Message from worker: " + data); // 3
});

worker.postMessage([5, 5]); // 1

// worker.js
addEventListener("message", (event) => {
  postMessage(event.data[0] + event.data[1]); // 2
}, false)

Request-response communication

Both communicating with an IFrame and with a worker has problems. First, sending the request and handling the response is separated. The receiving side uses a global (or a per-worker) event listener, which is shared between the calls.

This is good for "notification-style" messages where one end wants to notify the other that something happened but not expecting a response. But notification-style messaging is rare. What is usually needed is a "request-response-style" messaging.

For example, one end uses an access token that the other one can refresh. The communication consists of a request to refresh and a response to that with the refreshed token. Or even when a user clicks on a button and the other side needs to handle this. This example seems like a "notification-style" message, but when the button needs to be disabled while the operation takes place (such as a save button) or there is a possibility of an error that the sender needs to know about, it's now a request-response.

The global (or per-worker) message handler is not suited for pairing responses to requests. The ideal solution would be to hide all these complications behind an async function call and use await to wait for the result:

const result = await add(10, 5);

Let's see how to make such a function!

Response identification

The first task is to know which request triggered a given response. The problem with using the global event handler is that it's all too easy to rely on only one communication happening at a time. When you test your webapp, you test one thing at a time but users won't be that considerate. You can't assume only one request-response will happen at any one time.

One solution is to use request identifiers to pair the responses to the requests.

This works, and even though it requires some coding, this is a good solution.

MessageChannel

Fortunately, there is a better solution built into the language. MessageChannel allows a dedicated communication channel attached to the postMessage call. The sending end can listen for messages on that channel which is separated from all other calls, and the receiving end can send its responses through the MessagePort it got.

A MessageChannel creates two MessagePorts, one for each end of the communication. Each port supports both the onmessage and the postMessage, similar to the basic cross-context communication channels.

// index.html
const worker = new Worker("worker.js");
// create a channel
const channel = new MessageChannel(); 

// listen on one end
channel.port1.onmessage = ({data}) => {
	console.log("Message from channel: " + data); // 3
};

// send the other end
worker.postMessage([15, 2], [channel.port2]); // 1

// worker.js
addEventListener("message", (event) => {
	// respond on the received port
  event.ports[0].postMessage(event.data[0] + event.data[1]); // 2
}, false)

To attach a message handler to a port, use port.onmessage = (event) => {...}. If you use addEventListener then you need to start the channel too:

port.addEventListener("message", (event) => {...});
port.start();

To attach a port to the postMessage call, use the second argument: postMessage(data, [channel.port2]). To use this port on the other end, use event.ports[0].postMessage().

By creating a new MessageChannel for each request, pairing the response is solved as whatever comes through that channel is the response to this particular request.

Error handling

An often overlooked aspect of request-response communication is error handling. When the receiving end has a problem and throws an Error, it should be propagated to the sender so that it can handle it appropriately.

The problem with postMessage is that it can only send one type of message, there is no postError call. How to signal an error then?

A possible solution is similar to how Node-style callbacks propagate errors using only a single function. These callbacks use an error and a result value together, and only one of them is defined:

(err, result) => {
	if (err) {
		// error
	} else {
		// result
	}
}

To implement the same with messages, use an object with error and result properties. When the former is non-null that indicates that an error happened.

// worker.js
try{
	event.ports[0].postMessage({result: event.data[0] + event.data[1]});
}catch(e) {
	event.ports[0].postMessage({error: e});
}

// index.html
channel.port1.onmessage = ({data}) => {
	if (data.error) {
		// error
	}else {
		// data.result
	}
};

Don't forget to wrap the receiving end in a try-catch to propagate runtime errors.

Using Promises

With a separated response channel and error propagation, it's easy to wrap the call in a Promise constructor.

const worker = new Worker("worker.js");

const add = (a, b) => new Promise((res, rej) => {
	const channel = new MessageChannel(); 

	channel.port1.onmessage = ({data}) => {
		channel.port1.close();
		if (data.error) {
			rej(data.error);
		}else {
			res(data.result);
		}
	};

	worker.postMessage([a, b], [channel.port2]);
});

console.log(await add(3, 5)); // 8

With a Promise hiding all the complexities of the remote call, everything that works with async/await works with these calls too:

// parallel execution
console.log(await Promise.all([
	add(1, 1),
	add(5, 5),
])); // [2, 10]

// async reduce
console.log(await [1, 2, 3, 4].reduce(async (memo, i) => {
	return add(await memo, i);
}), 0); // 10

And the Promise rejects when it should, allowing proper error handling on the sending end:

// worker.js
addEventListener("message", (event) => {
  try{
    if (typeof event.data[0] !== "number" || typeof event.data[1] !== "number") {
      throw new Error("both arguments must be numbers");
    }
    event.ports[0].postMessage({result: event.data[0] + event.data[1]});
  }catch(e) {
    event.ports[0].postMessage({error: e});
  }
}, false)

// index.html
try {
	await add(undefined, "b");
}catch(e) {
	console.log("Error: " + e.message); // error
}

One restriction is what can be sent through the postMessage call. It uses the structured clone algorithm which supports complex objects but not everything.

Conclusion

The postMessage call allows communication between different contexts, such as cross-domain IFrames and web workers. But this is a rudimentary channel that lacks support for request-response style messages and error propagation. With the use of MessageChannel and a result structure that allows returning errors, it's possible to implement a robust solution. Taking one step further, using the Promise constructor to hide the complexities of the message handling allows the use of async/await.

September 11, 2020
In this article