Adapting event-driven APIs to Promises in JavaScript
Event-driven architecture (EDA) is a powerful way of building loosely coupled, performant, scalable apps on the web. It enables rich experiences like push notifications, collaborative editing and multiplayer, and other real-time interactions, and encourages modularity.
But sometimes the model is inconsistent with what we as developers need to do. When two application layers can only communicate via asynchronous message passing, we can be forced to structure our code awkwardly. We can’t collocate the requesting code with the receiving code, and are responsible for managing listeners or subscriptions ourselves.
In this article, I’ll demonstrate a general solution to adapt event-based APIs into convenient, Promise-based APIs that hide the complexity and boilerplate of message passing, and allow you to write linear code across application boundaries.
Request/Response vs. EDA
Traditional applications on the web handle communication across boundaries over HTTP — via REST, GraphQL, RPC, and other specifications following the request/response model. This model is characterized by a requester sending a message, then waiting for a responder to receive, process, and reply to the message before continuing execution. Though this may take place in async functions in JavaScript, we can refer to this model as generally “synchronous” or “in-band,” in that a response is expected immediately following a request, and that the context executing the request will wait for it.
In EDA, also called the publish/subscribe model, the processes of requesting and receiving data are separate and performed in a non-blocking, asynchronous manner. Typically a client will subscribe to messages from a server, and a server will subscribe to messages from the client. When a client requests data, it simply sends a message, then carries on with its execution. The server receives this message, processes it, and at some point sends another message to the client. The client, as a subscriber, receives this message “out-of-band” from its original request, and processes the message as it sees fit. Critically, this can happen at another time, using a different network request, or even another protocol.
Take this analogy — the request/response model is a phone call between two friends, and pub/sub is a group text: not every message is meant for you, and you can decide which messages to respond to and when — though you are expected to eventually respond to certain messages. In 2023, texting or other asynchronous messaging is usually the right mode of communication. But sometimes you just need to pick up the phone.
A few key advantages arise in the capabilities of the event-driven model. First, EDA allows clients to be notified of events on the server, regardless of having asked for it. This eliminates expensive polling, and enables “push” behavior for notifications and events originating elsewhere. Second, it can encourage less coupled code by allowing message handling to be separated from message sending. Third, it empowers developers to parallelize processing and build resilient, comprehensible systems. Fourth, it makes systems inherently fault-tolerant, as only recognized messages are handled by subscribers.
Web technologies like Webhooks, WebSockets, and Server-Sent Events, protocols like MQTT and AMQP, and numerous tools built on top of these can all be used to implement robust event-driven applications today.
When EDA gets in the way
In complex apps, event-driven architectures can provide tons of advantages. But sometimes you need the data right away, in a specific execution context. And sometimes we simply want to treat a remote resource or procedure as if it is a local one.
As a somewhat contrived example, let’s say we have an application that has to perform some expensive computation on user input. We decide that this computation is best done in a Web Worker, which uses a separate thread to do the work without burning cycles on the main UI thread. We set up some simple logic to communicate with a worker from our app:
Our Worker module listens for messages from the main thread, performs the expensive computation, and sends a response to the main thread with the result:
This does what we expect. The client can send a request for a computation, then later receive the result and doSomethingWithResult. But this solution limits us in terms of where we can perform an expensiveComputation. We can’t request and use the response in the same place. This can pose a challenge when trying to use the functionality from contexts we have little control over, like external library code, or in the middle of an async function. It would be nice to be able to say, “I need this data, and I’ll wait for it right here.”
Enter the Sync Bridge.
The Sync Bridge
In order to consume the event stream in a “synchronous” manner, we’ll need to convert its interface into one using Promises — a Sync Bridge. The process of converting an event-based API to a Promise-based one can be broken down into a few steps:
- Before a message is sent from the client, attach an id or some means of uniquely identifying the requester to the message. This is our “return address.”
- Create an “empty” Promise and store a reference to its resolve and reject callbacks, associated to the message’s id, in some pending data structure. A Map is a good choice.
- Return the Promise to the requester, and send the message.
- Subscribe to client messages on the host. When a message bearing an id is received, process it as usual, but include the same id on the response message.
- Subscribe to host messages on the client. When a message bearing an id is received, check to see if pending contains any pending promises for that id. If it does, pop it from the data structure and resolve or reject the Promise, whatever is appropriate based on the contents of the host message.
Here “client” and “host” can be any entity that participates in message passing. Sometimes a client can act as a host and vice versa, so we can also refer to these entities as “requester” and “responder” based on the context in which they are used.
We can escape the limitations of EDA by creating a contract between the client and host, agreeing to mark request and corresponding response messages with a common, unique ID, enabling responses to be “routed” to the right requester. We leave a Promise open on the request side as a placeholder, which we fill in with real data once the message we are waiting on is received.
To apply this to our earlier Web Worker example, let’s write some helper classes to abstract the processes listed above. We’ll want some sort of client abstraction to assign IDs to messages, keep track of pending requests, and listen for responses. We’ll call this a WorkerClient :
On the host side, we’ll want a controller that filters out the messages we’re not interested in, performs some unit of work, and sends messages back to the requester’s “return address.” A proxy message handler, if you will. We’ll call this WorkerHost :
Using these helpers, we’ll rewrite our application code to consume our new Promise-based API. Note that we can now await the data directly in our click handler:
Our worker looks quite similar too, with the exception that our handlers are now just returning values instead of posting messages (the message passing is taken care of for us):
OK, we’ve written a fair amount of code. What exactly have we gained?
The Sync Bridge adaptor essentially reframes the expectation of receiving some message in the future as an actual Promise. It allows us to treat data and code in a remote context as if it were local. Most importantly, it allows us to request and use remote data in the same place. If we need to do an expensiveComputation in the middle of a database transaction, some arbitrary event handler, or even in the message handler of another event stream, we just pick up the phone.
We can also now restrict messages of different types to discrete channels, keeping message handling specific, fast, and localizing code to only where it is needed. If we want, multiple WorkerClient objects can even share the same channel.
This pattern can be easily generalized to most event-driven systems. We could modify the helpers in our example to take in any EventTarget instead of constructing its own Worker, providing a generic, synchronous interface to any message stream. Or we could write drop-in wrapper libraries for specific interfaces, as I recently did with figments — a set of “bridged” wrappers around event-driven parts of the Figma Plugin API.
Side note: if there’s an existing term for the technique we’ve applied here — adapting an event-based asynchronous interface to a promise/future-based one — please let me know in the comments! Until then, I will continue to call it a “Sync Bridge.”
In the real world
While it is best to start “thinking in events” when working with event-driven systems, you sometimes need an escape hatch. A Sync Bridge can be a useful tool here, but consider if this approach is necessary before implementing it. Mostly, eventing just works.
Hope you have an… eventful day!
FURTHER READING & RESOURCES
---------------------------
Example code from this article
• rektdeckard/promisize
More on EDA
• Event-driven APIs – Understanding the Principles
• What the heck is event-driven architecture?
Deep dives into async/await and Promises
• What the heck is the event loop anyway?
• Loupe
• sindresorhus/promise-fun
ME AND STUFF I DO
-----------------
• GitHub
• Phosphor Icons
🙏 Thanks to Helena Zhang for being my illustrator and rubber duck, and to Tariq Rauf for giving this pattern a name.
Building a Sync Bridge was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.