Pub/Sub, From First Principles: Part 1 — Foundations

Part 1 of 4 in the Pub/Sub, From First Principles series.

Pub/Sub (short for Publisher-Subscriber) is a messaging pattern that lets components communicate without knowing about each other directly: publishers emit events, subscribers react to them, and neither side holds a reference to the other. It's one of the core communication patterns underneath event-driven architecture (EDA) — when services react to order.created or user.registered events instead of calling each other directly, some form of pub/sub is what's carrying those events. That decoupling is what makes it worth reaching for: a component can react to something happening elsewhere without being wired into the code that caused it.

This four-part series builds the pattern up from the simplest possible version to the production concerns that show up once real traffic and real failures enter the picture. The implementations are intentionally simplified, in-memory, and single-process — the point is to understand the mechanics from the inside. Production systems like Kafka, RabbitMQ, or AWS SNS/SQS solve the same problems plus a layer of distributed-systems concerns (persistence, replication, network failures) that an in-memory version never has to face; where the concepts map onto those tools, the series points it out.

At its core, pub/sub decouples two roles: publishers, which emit events without knowing who's listening, and subscribers, which react to events without knowing who published them. Here's the simplest possible version — one array of subscriber functions, and a loop that calls each one when something is published.

// pubsub-basic.ts
export type Subscriber = (message: unknown) => void;

class BasicPubSub {
  private subscribers: Subscriber[] = [];

  subscribe(subscriber: Subscriber): void {
    this.subscribers.push(subscriber);
  }

  unsubscribe(subscriber: Subscriber): void {
    const i = this.subscribers.indexOf(subscriber);
    if (i !== -1) this.subscribers.splice(i, 1);
  }

  publish(message: unknown): void {
    for (const subscriber of this.subscribers) {
      subscriber(message);
    }
  }
}

A concrete use case makes this tangible: a user.registered event fires once, and several independent subscribers react to it — one sends a welcome email, another starts an onboarding checklist, a third logs it for analytics.

                          ┌──> [Email subscriber]      sends welcome email
[Publisher] ──publish──── ┼──> [Onboarding subscriber] starts checklist
 (signup code)            └──> [Analytics subscriber]  logs the event
const ps = new BasicPubSub();
ps.subscribe((user) => sendWelcomeEmail(user));
ps.subscribe((user) => startOnboardingChecklist(user));
ps.subscribe((user) => logSignupEvent(user));

ps.publish({ id: "u_123", email: "new-user@example.com" });

None of these subscribers know about each other, and the signup code that published the event doesn't know they exist either — that's decoupling in practice, not just in theory.

Edge case: what happens if a subscriber crashes? Badly, as written. for...of doesn't catch anything, so if the welcome-email subscriber throws, the onboarding and analytics subscribers never run at all — one bad subscriber takes the rest of that publish down with it.

Edge case: what happens if the publisher outpaces the subscriber? In this version, it can't. publish() is synchronous and blocking — it doesn't return until every subscriber has finished — so the caller is automatically throttled to the slowest subscriber's speed, whether it wants to be or not. That's not backpressure by design, it's just blocking, and it's also the real problem here: a slow welcome-email provider now determines how fast the signup flow itself can run.

Next up: what happens if one subscriber is slow, or throws, and you don't want it to affect anyone else — or block the publisher? Continue to Part 2: Asynchronous Workers.

Pub/Sub, From First Principles: Part 1 — Foundations - Andrew Rogerson