How to use async/await with postMessage
Use MessageChannel and error propagation to use async/await with cross-context communication
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.