Building a Simple Pub/Sub System in Node.js
Publish/subscribe decouples the code that produces an event from the code that reacts to it — the publisher doesn't know or care who's listening, and subscribers don't know or care where the event came from. You reach for a message broker like Redis or RabbitMQ once you need this across processes, but the pattern itself is small enough to build from scratch in-process, and understanding that version makes the broker-backed ones much less mysterious.
The core idea
A pub/sub system has three moving parts: topics (named channels), subscribers (functions registered against a topic), and a publish operation that calls every subscriber currently registered for a topic.
class EventBus {
#topics = new Map();
subscribe(topic, handler) {
if (!this.#topics.has(topic)) {
this.#topics.set(topic, new Set());
}
this.#topics.get(topic).add(handler);
// return an unsubscribe function — the caller owns cleanup
return () => this.#topics.get(topic)?.delete(handler);
}
publish(topic, payload) {
const handlers = this.#topics.get(topic);
if (!handlers) return;
for (const handler of handlers) {
handler(payload);
}
}
}
That's a working in-memory event bus in under 20 lines. Node's built-in EventEmitter does the same job, but building your own once is worth it for the parts a full broker adds on top: async handling, error isolation, and wildcard topics.
Using it
const bus = new EventBus();
const unsubscribe = bus.subscribe('order.created', (order) => {
console.log(`Sending confirmation email for order ${order.id}`);
});
bus.subscribe('order.created', (order) => {
console.log(`Reserving inventory for order ${order.id}`);
});
bus.publish('order.created', { id: 42, total: 89.99 });
// Sending confirmation email for order 42
// Reserving inventory for order 42
unsubscribe(); // the email handler stops receiving events
Two independent concerns — emailing and inventory — react to the same event without knowing about each other. Add a third concern (say, analytics) later, and you subscribe it without touching the code that publishes order.created.
Handling async subscribers
The naive publish above calls handlers synchronously and ignores whether they return a promise. If a handler does async work (writing to a database, calling an API), you usually want to know if it failed, and you don't want one slow handler to block the others.
async publish(topic, payload) {
const handlers = this.#topics.get(topic);
if (!handlers) return [];
const results = await Promise.allSettled(
[...handlers].map((handler) => handler(payload)),
);
for (const result of results) {
if (result.status === 'rejected') {
console.error('Subscriber failed:', result.reason);
}
}
return results;
}
Promise.allSettled runs every handler concurrently and lets each succeed or fail independently — one broken subscriber doesn't stop the others from running, and you get visibility into which one failed instead of an unhandled rejection swallowing the error.
Wildcard and namespaced topics
Real systems often want to subscribe to a family of events (order.*) rather than one exact topic. A simple approach: store subscribers under exact topic strings, but check both exact and wildcard matches on publish.
class EventBus {
#topics = new Map();
subscribe(pattern, handler) {
if (!this.#topics.has(pattern)) this.#topics.set(pattern, new Set());
this.#topics.get(pattern).add(handler);
return () => this.#topics.get(pattern)?.delete(handler);
}
#matches(pattern, topic) {
if (pattern === topic) return true;
if (!pattern.endsWith('.*')) return false;
const prefix = pattern.slice(0, -1); // keep the trailing dot
return topic.startsWith(prefix);
}
async publish(topic, payload) {
const matched = [...this.#topics.entries()]
.filter(([pattern]) => this.#matches(pattern, topic))
.flatMap(([, handlers]) => [...handlers]);
return Promise.allSettled(matched.map((handler) => handler(payload)));
}
}
Now bus.subscribe('order.*', logAnyOrderEvent) catches order.created, order.cancelled, and anything else under the order. namespace, alongside handlers subscribed to the exact topic.
Where this stops being enough — and you need a real broker
This in-process bus is genuinely useful for decoupling modules within a single application. It breaks down once you need any of the following, which is where Redis Pub/Sub, RabbitMQ, or Kafka take over:
- Cross-process or cross-service delivery. Your event bus only exists inside one Node process. Two instances of your app behind a load balancer don't share subscribers.
- Durability. If nothing is subscribed when you publish, the event above is simply gone. A real broker (or at least a queue) persists messages so a subscriber that starts up later can still receive them.
- Backpressure and ordering guarantees.
Promise.allSettledgives you concurrency, not flow control — a slow consumer doesn't get throttled, it just falls behind while producers keep firing. - At-least-once delivery with acknowledgment. Real message brokers let a consumer explicitly ack a message, and redeliver it if the consumer crashes before acking.
When the in-process version is the right call
If your "subscribers" are all within one Node process — decoupling a webhook handler from an email sender from an audit logger, all inside one service — a small EventBus like this is often the correct amount of infrastructure. Reaching for Kafka to connect two functions in the same codebase is solving a problem you don't have yet. Start here, and graduate to a broker when you actually need delivery across process boundaries, not before.