Pub Sub
Prompt
Implement a PubSub class in JavaScript that supports the following methods:
subscribe(event, callback): Registers a listener for the event.publish(event, data): Notifies all listeners of the event with the given data.publishAll(data): Publishes data to all registered events.subscribeOnce(event, callback): Registers a listener that fires only once, then automatically removes itself.subscribeOnceAsync(event): Returns a promise that resolves the first time the event is published.
Playground
If you've built the Event Emitter, you're already halfway
there. subscribe works the same way as on: check if
the event exists in this.events, if not create an empty
array for it, then push the callback. publish works like
emit: look up the event, loop through its callbacks,
call each one with the data. Start with these two, they're
the foundation for everything else.
For publishAll, you don't need to reinvent anything.
Loop through every key in this.events using
Object.keys() and call your own publish method for
each one with the same data. Reusing publish keeps it
clean.
For subscribeOnce, don't subscribe the original callback
directly. Create a wrapper function that does two things:
calls the original callback with the data, then
immediately unsubscribes itself (the wrapper, not the
original callback). Subscribe the wrapper instead. When
the event fires, the wrapper runs once and removes itself,
so it never fires again.
For subscribeOnceAsync, return a new Promise. Inside
the Promise constructor, use your subscribeOnce method
and pass resolve directly as the callback. When the
event fires, resolve gets called with the data and the
Promise fulfills. Because you used subscribeOnce, the
listener cleans itself up automatically. The whole method
is just two lines.
Solution
Explanation
If you've already built the Event Emitter, you can build Pub/Sub. It's the same core idea with a few extras on top.
But before we get into the code, I want you to understand something about this pattern that might change how you see software.
Pub/Sub is everywhere
Every time you use Redux, you're using Pub/Sub. dispatch is just publishing. connect is just subscribing. Different names, same pattern.
Every time you write addEventListener('click', fn), that's Pub/Sub. You're subscribing to the "click" event. The browser publishes it when the user clicks.
WebSockets, Kafka, RabbitMQ, Node.js EventEmitter, React Context, browser postMessage... all Pub/Sub. Different layers of software, same underlying idea: someone broadcasts a message, and anyone who cares about that message receives it.
Once you internalize this, you stop seeing these as separate technologies and start seeing one pattern wearing different costumes.
Building it from Event Emitter
subscribe and publish are just on and emit with different names. If you've written an Event Emitter before, this is the same thing:
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
publish(event, data) {
if (this.events[event]) {
this.events[event].forEach((callback) => callback(data));
}
}Nothing new. Where it gets interesting is the three methods we build on top.
publishAll
Sometimes you need to send the same message to every channel at once. Think of it like a system-wide announcement. publishAll just loops through every event that has subscribers and calls publish for each:
publishAll(data) {
Object.keys(this.events).forEach((event) => {
this.publish(event, data);
});
}We reuse our own publish method here instead of duplicating the logic. That's intentional. If publish ever changes, publishAll stays correct automatically.
subscribeOnce
This one is really satisfying when it clicks.
You want a listener that fires once and then removes itself. The question is: how do you make a function delete itself after running?
You don't. Instead, you wrap it.
subscribeOnce(event, callback) {
const wrapper = (data) => {
callback(data);
this.unsubscribe(event, wrapper);
};
this.subscribe(event, wrapper);
}We don't subscribe the original callback. We subscribe a wrapper function. When the event fires, wrapper runs. It does two things: calls your callback so you get your data, and then unsubscribes itself. Next time the event fires, wrapper is gone.
If this "wrap a function to change its behavior" pattern feels familiar, it should. Debounce wraps a function to delay it. Throttle wraps a function to limit it. Memoize wraps a function to cache it. Here, we wrap a function to make it self-destruct. Same technique, different purpose.
subscribeOnceAsync
This is my favorite part, and it's only two lines of code:
subscribeOnceAsync(event) {
return new Promise((resolve) => {
this.subscribeOnce(event, resolve);
});
}Stop and read that again. We create a Promise. We use subscribeOnce to listen for the event. The callback we pass is resolve itself. When the event fires, resolve gets called with the event data, and the Promise fulfills. Because we used subscribeOnce, the listener cleans itself up automatically.
Two lines. That's it. And it unlocks something powerful. You can now write:
const data =
await pubsub.subscribeOnceAsync('userLoggedIn');
console.log('User logged in:', data);That line waits until the userLoggedIn event fires, then continues. No callback nesting, no .then() chains. Just straight, readable, top-to-bottom code.
This is how you bridge the gap between events (callback world) and promises (async/await world). If you've ever wondered how Node.js util.promisify works, it's doing exactly this: wrapping a callback-based function so it returns a Promise instead.
Why interviewers love this question
Each method in this class is small. None of them are complicated on their own. What the interviewer is really watching for is whether you build each method on top of the previous ones, or whether you write each one from scratch.
publishAllcallspublishsubscribeOncecallssubscribeandunsubscribesubscribeOnceAsynccallssubscribeOnce
The best solutions are the ones where every new method is one or two lines because it reuses what's already there. That's composability, and it's what separates engineers who write maintainable code from those who don't.