vteh.extratechsolutions

Publish/Subscribe

The publish/subscribe pattern decouples producers from consumers through a broker, enabling fan-out messaging, independent scaling, and resilient async integration.

Demo coming soonintermediatepub-submessagingasyncdecoupling

Point-to-point integration is where healthy systems go to calcify. A team ships an order service, and marketing asks it to notify their analytics pipeline. Then fulfillment needs the same event. Then fraud. Then the data warehouse. Each new consumer is another outbound call wired directly into the order service, another timeout to tune, another failure mode that can wedge checkout. The publish/subscribe pattern breaks this coupling by inserting a broker between producers and consumers: publishers emit events to a named topic and forget about them; subscribers register interest and receive their own copy. Nobody on either side knows the other exists.

This article is a practitioner's guide to using pub/sub well — where it earns its keep, where it quietly ruins your on-call rotation, and the details (ordering, delivery guarantees, poison messages, schema evolution) that separate a demo from a production system.

The problem

Direct integration between services scales badly, and it scales badly in a specific, predictable way. With N producers each needing to reach M consumers, you trend toward N×M bespoke connections. Every connection carries its own retry policy, its own serialization assumptions, its own credentials, and its own latency budget. The integration surface grows faster than the feature set.

Worse than the connection count is the failure coupling. When the order service calls the analytics service synchronously to report a new order, the analytics service's health is now a dependency of checkout. A slow consumer becomes a slow producer. A downstream outage becomes an upstream outage. Synchronous call chains amplify failure: a 200 ms hiccup three hops away surfaces as a user-facing timeout, and retries stack into a thundering herd exactly when the system is least able to absorb it.

The coupling is also organizational. To add a new consumer of order events, you file a ticket against the order team, they add another call, they redeploy, and they now own an integration they don't care about. The team that produces the data becomes a bottleneck for every team that wants to use it. This is integration spaghetti: not merely tangled wiring, but a topology where every new relationship requires changing code that already works.

Pub/sub inverts the dependency. Publishers depend only on the broker and a topic name. Consumers depend only on the broker and a subscription. The N×M mesh collapses into N+M edges against a shared bus, and the producer no longer knows — or needs to be redeployed to learn — that a new consumer arrived.

When to use it

Reach for pub/sub when these conditions hold:

  • One event, many interested parties. A single business fact ("order placed", "user signed up", "payment captured") needs to trigger independent reactions in unrelated subsystems. Fan-out is the pattern's core competency.
  • Producers and consumers should evolve independently. You want to add, remove, or redeploy a consumer without touching the producer. Team autonomy is a first-class goal.
  • The interaction is genuinely fire-and-forget. The producer's job is done once the event is durably accepted by the broker. It does not need, and should not wait for, a result.
  • You need to absorb load spikes. The broker acts as a buffer. A burst of publishes drains to consumers at their own pace, smoothing traffic and protecting slow downstreams.
  • You want an audit-friendly integration spine. Because events are named, typed, and durable, the topic becomes a natural observation point and, with retention, a replayable record.

Pub/sub shines in event-driven architectures: order pipelines, activity streams, cache invalidation fan-out, notification systems, and the read-model projections that pair naturally with event sourcing.

When NOT to use it

Pub/sub is a decoupling tool, and decoupling is not free. Avoid it when:

  • You need a reply. If the caller must have a result to proceed — a price quote, an authorization decision, a validated write — that is request/response, not publish/subscribe. Bolting a correlation-id "response topic" onto a broker to fake synchronous calls reinvents RPC with worse latency and worse debuggability. Use an HTTP or gRPC call.
  • You need strict global ordering. Most brokers guarantee ordering only within a partition or ordering key, never across an entire topic. If your correctness depends on a single total order of all events, pub/sub will fight you, and the workarounds (single partition, single consumer) throw away the throughput that justified the pattern.
  • You are coordinating a multi-step workflow. If step B must run only after step A succeeds, and a failure at C must compensate A and B, you are describing a distributed transaction. That is a saga, which uses messaging underneath but adds orchestration, state, and compensation semantics that raw pub/sub does not provide. Don't hand-roll workflow logic out of loose event handlers.
  • Two services could just share a call or a database. If exactly one consumer exists and always will, and the producer already knows about it, a direct call is simpler, easier to trace, and easier to reason about. A broker between two tightly-coupled services adds a moving part, an operational dependency, and a layer of indirection that buys you nothing. Introduce the bus when the second or third consumer appears, not speculatively.

The honest summary: pub/sub trades immediate, traceable, synchronous coupling for eventual, resilient, asynchronous decoupling. When you don't need the decoupling, you're paying the cost of eventual consistency and distributed debugging for no benefit.

Architecture

A publisher writes an event to a topic. The broker retains it and delivers an independent copy to every subscription. Each subscriber processes at its own pace; messages that repeatedly fail are diverted to a dead-letter queue for inspection rather than blocking the stream.

flowchart LR
    P1[Order Service] -->|OrderPlaced| T((orders topic))
    P2[Payment Service] -->|PaymentCaptured| T
    T --> S1[Analytics Subscription]
    T --> S2[Fulfillment Subscription]
    T --> S3[Fraud Subscription]
    S1 --> A[Analytics Worker]
    S2 --> F[Fulfillment Worker]
    S3 --> R[Fraud Worker]
    R -->|repeated failure| DLQ[(Dead-letter queue)]

Three structural facts matter. First, fan-out: the broker duplicates each event per subscription, so consumers are fully isolated — fraud being slow does not delay fulfillment. Second, the subscription is the unit of delivery state, not the topic; each subscription tracks its own acknowledgment position, so a new subscriber can start from now (or, with retention, from the past) without affecting anyone else. Third, the dead-letter queue is where a message goes after exhausting its redelivery attempts, converting an unbounded retry loop into a bounded one plus an alert.

Code walkthrough

The core idea is broker-agnostic: a typed event envelope, a publisher that stamps every message with a stable id, and a subscriber that is idempotent because at-least-once delivery guarantees you will see duplicates.

Start with the envelope. Every event carries metadata that outlives any single broker: a unique id for deduplication, a type and version for routing and schema evolution, and a key for partition-level ordering.

interface EventEnvelope<T> {
  id: string;          // globally unique; the dedupe key (e.g. UUID v4)
  type: string;        // "order.placed"
  version: number;     // schema version of `data`, for evolution
  key: string;         // ordering/partition key (e.g. orderId)
  occurredAt: string;  // ISO-8601, set by the producer
  data: T;             // the typed payload
}

interface OrderPlaced {
  orderId: string;
  customerId: string;
  totalCents: number;
}

Separating id (unique per message) from key (shared by related messages) is the crux. The id powers idempotency; the key powers ordering. Conflating them breaks one or the other.

The publisher is thin. It wraps the payload, assigns identity, and hands off to whatever transport is configured. Its only real responsibility is ensuring the message is durably accepted before reporting success.

interface Broker {
  publish(topic: string, envelope: EventEnvelope<unknown>): Promise<void>;
}

class Publisher {
  constructor(private broker: Broker) {}

  async emit<T>(topic: string, type: string, key: string, data: T): Promise<void> {
    const envelope: EventEnvelope<T> = {
      id: crypto.randomUUID(),
      type,
      version: 1,
      key,
      occurredAt: new Date().toISOString(),
      data,
    };
    await this.broker.publish(topic, envelope);
  }
}

Note what the publisher does not do: it does not wait for consumers, know how many there are, or care whether any exist. In production, pair emit with the transactional outbox pattern so the domain write and the publish commit atomically — otherwise a crash between them silently drops the event.

The subscriber is where correctness lives. Because delivery is at-least-once, the same id can arrive more than once (a redelivery after a slow ack, a broker failover, a retry). An idempotent handler makes duplicates harmless by recording processed ids and short-circuiting repeats.

interface ProcessedStore {
  seen(id: string): Promise<boolean>;
  markProcessed(id: string): Promise<void>;
}

class Subscriber<T> {
  constructor(
    private store: ProcessedStore,
    private handle: (data: T) => Promise<void>,
    private maxAttempts = 5,
  ) {}

  // Called by the broker adapter for each delivery. Returns whether to ack.
  async onMessage(envelope: EventEnvelope<T>, attempt: number): Promise<'ack' | 'retry' | 'dead-letter'> {
    if (await this.store.seen(envelope.id)) return 'ack'; // duplicate: already handled
    try {
      await this.handle(envelope.data);
      await this.store.markProcessed(envelope.id); // ideally in the same tx as the handler's write
      return 'ack';
    } catch (err) {
      return attempt >= this.maxAttempts ? 'dead-letter' : 'retry';
    }
  }
}

The seen/markProcessed pair should share a transaction with the handler's own write when possible; otherwise a crash after the side effect but before markProcessed reprocesses on redelivery. If the underlying operation is naturally idempotent (an upsert keyed by orderId), you can lean on that instead of a dedupe table. Either way, "process exactly once" is achieved not by the broker but by the consumer choosing to tolerate repeats. A message that exhausts maxAttempts returns 'dead-letter' — a poison message must never block the partition behind it.

Broker differences matter when you map this core onto a real system:

  • Apache Kafka is a partitioned, retained log. "Subscriptions" are consumer groups reading offsets; ordering is guaranteed per partition (choose your key deliberately), and replay is just seeking an offset. There is no per-message ack — you commit offsets.
  • AWS SNS + SQS gives true fan-out: SNS publishes to a topic, each subscribing SQS queue gets its own copy with independent retries and a native DLQ. Standard queues are at-least-once and unordered; FIFO queues add ordering and dedupe within a message group at lower throughput.
  • Google Cloud Pub/Sub offers push or pull subscriptions, at-least-once by default, optional ordering keys, and built-in dead-letter topics after a configurable delivery count.
  • Redis (Pub/Sub or Streams) is the lightweight option. Classic Pub/Sub is fire-and-forget with no persistence; Redis Streams adds consumer groups, acks, and retention — closer to Kafka semantics but in-memory-bound.

Keep the envelope and handler logic broker-agnostic; confine broker specifics to a thin adapter implementing Broker and translating 'ack' | 'retry' | 'dead-letter' into the transport's primitives.

Performance characteristics

The metrics in the panel above are indicative ranges, not measured benchmarks for this system — treat them as order-of-magnitude expectations to be replaced by your own load tests.

Fan-out latency is dominated by the broker hop and the number of subscriptions, not by consumer count in the way synchronous fan-out is. Delivering to ten subscribers costs roughly one publish plus ten independent reads, all parallelizable, rather than ten serial calls on the producer's critical path. In-region, a single broker hop is typically low single-digit to low double-digit milliseconds; the tail is set by consumer lag and redelivery, not the happy path.

Throughput varies by an order of magnitude or more across broker classes. A well-partitioned log-based broker (Kafka-class) sustains hundreds of thousands to millions of messages per second because throughput scales horizontally with partitions and consumer-group members. Managed cloud brokers (SNS/SQS, Google Pub/Sub) trade some peak throughput for zero operational burden, commonly landing in the thousands to low hundreds of thousands per topic before you tune quotas and batching. The lever that most affects both latency and throughput is batching: larger batches raise throughput and raise per-message latency.

Delivery guarantees cost throughput and latency. At-most-once (fire-and-forget, no ack) is fastest and loses messages on failure. At-least-once (ack-and-redeliver) is the pragmatic default and forces idempotent consumers, as above. Effective exactly-once requires either broker-side transactions (Kafka's transactional producer/consumer) or consumer-side dedupe, and both add coordination overhead. Stronger ordering guarantees (FIFO queues, single-partition topics) similarly cap parallelism. The engineering question is never "how do I get the strongest guarantee" but "what is the weakest guarantee my consumers can be made correct under" — usually at-least-once plus idempotency.

Two failure modes deserve monitoring budget. Consumer lag — the gap between the newest published offset and a subscription's committed offset — is the leading indicator that a consumer can't keep up; alert on it before the buffer's retention window drops undelivered messages. Dead-letter volume is your poison-message signal; a rising DLQ means a systematic bug, a schema mismatch, or a bad deploy, and it should page someone.

Live demo

An interactive publish/subscribe demo — publish events to a topic and watch multiple subscribers fan out in real time, complete with a simulated poison message routing to a dead-letter queue — is coming soon. It will run on the live tooling at stop.procrastin.ar and be linked here on launch.

Related patterns

  • Message queue — the key contrast. A message queue delivers each message to exactly one consumer among competing workers; it is a load-distribution tool (one job, one worker). Pub/sub delivers each message to every subscription; it is a fan-out tool (one event, many reactions). The two compose: a topic fans out to several subscriptions, and each subscription is drained by a pool of competing consumers. Confusing "queue" with "topic" is the most common pub/sub design error — reach for a queue when work must be done once, a topic when a fact must be observed by many.
  • Event sourcing — composes naturally. When your source of truth is an append-only log of events, publishing those same events to subscribers turns the store into an integration spine, feeding read-model projections and downstream services from the identical stream that reconstitutes state. Schema evolution discipline (the version field, additive-only changes, tolerant readers) becomes doubly important because events are now both your history and your contract.
  • Saga — composes for workflows pub/sub alone can't coordinate. When a business process spans services and needs ordered steps with compensation on failure, a saga rides on top of the messaging layer, using published events to advance state and trigger compensating actions. Pub/sub moves the messages; the saga supplies the coordination, state, and rollback semantics that loose event handlers lack.

Related patterns

Discussion