HardLinkedIn

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

Hint 1

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.

Hint 2

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.

Hint 3

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.

Hint 4

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.

  • publishAll calls publish
  • subscribeOnce calls subscribe and unsubscribe
  • subscribeOnceAsync calls subscribeOnce

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.