JavaScript Patterns
12 min read

Publish-Subscribe Pattern in JavaScript

Architectural principles, implementation trade-offs, and production patterns for event-driven systems. Covers the three decoupling dimensions, subscriber ordering guarantees, error isolation strategies, and when Pub/Sub is the wrong choice.

emit

emit

dispatch

dispatch

dispatch

Publisher 1

Event Bus / Broker

Publisher 2

Subscriber 1

Subscriber 2

Subscriber N

Pub/Sub architecture: publishers emit to a broker that dispatches to all registered subscribers

Mental Model: Pub/Sub trades explicit control flow for decoupling. Publishers fire events into a broker without knowing who (if anyone) receives them. Subscribers register interest without knowing who produces events. The broker is the single point of coupling.

Trade-off

Decoupling

Debuggability

Three Decoupling Dimensions

Space: Who?

Time: When?

Sync: Blocking?

Pub/Sub provides space, time, and synchronization decoupling—at the cost of implicit control flow

Core Trade-off: Loose coupling enables independent component evolution and many-to-many communication. The cost is implicit control flow—debugging requires tracing events across the system rather than following function calls.

Key Implementation Decisions:

DecisionRecommendationWhy
Subscriber storageMap<string, Set<Function>>O(1) add/delete, prevents duplicates, preserves insertion order (ES6+ guarantee)
Error isolationtry/catch per subscriberOne failing subscriber must not break others
Async publishReturn Promise.allSettled()Await completion without short-circuiting on errors
CleanupReturn unsubscribe functionPrevents memory leaks; ties to component lifecycle

When NOT to Use:

  • Request-response patterns (use direct calls)
  • Single known recipient (Observer pattern suffices)
  • When you need delivery guarantees (Pub/Sub doesn’t guarantee delivery)
  • Simple systems unlikely to scale (unnecessary indirection)

Eugster et al.’s foundational paper “The Many Faces of Publish/Subscribe” defines pub/sub as providing “full decoupling of the communicating entities in time, space, and synchronization”:

  1. Space Decoupling: Publishers emit events without knowledge of which subscribers (if any) will receive them. Subscribers register without knowing which publishers produce events. Neither knows the other’s identity, location, or count.

  2. Time Decoupling: Publishers and subscribers need not be active simultaneously. In distributed systems (MQTT, AMQP), a subscriber can receive messages published while it was offline. In-process implementations typically lack this—events are lost if no subscriber exists at publish time.

  3. Synchronization Decoupling: Publishing is non-blocking. The publisher hands the event to the broker and continues immediately. This contrasts with RPC, which the paper notes has “synchronous nature… [which] introduces a strong time, synchronization, and also space coupling.”

AspectObserverPub/Sub
CouplingSubject knows observers directlyPublishers and subscribers unknown to each other
IntermediaryNone—Subject IS the dispatcherBroker/Event Bus required
CardinalityOne-to-manyMany-to-many
Typical scopeIn-process, single componentCross-component, potentially distributed
Use caseUI state binding, reactive streamsModule communication, microservices

Observer is appropriate when a single subject owns the state and notifies dependents. Pub/Sub is appropriate when multiple independent components need to communicate without direct references.

A minimal implementation using ES6+ Set for O(1) subscriber management:

basic-pub-sub.ts
type Callback<T> = (data: T) => void
export class PubSub<T> {
#subscribers = new Set<Callback<T>>()
subscribe(callback: Callback<T>) {
this.#subscribers.add(callback)
return () => this.#subscribers.delete(callback)
}
publish(data: T) {
for (const subscriber of this.#subscribers) {
subscriber(data)
}
}
}

Why Set over Array:

  • O(1) add/delete vs O(n) for array splice
  • Automatic deduplication—same callback added twice is stored once
  • Insertion order guaranteed per ECMA-262: “Set objects iterate through elements in insertion order”

If you need the same callback to fire multiple times per event (rare), use an array instead.

Usage:

basic-usage.ts
const events = new PubSub<{ userId: string; action: string }>()
const unsubscribe = events.subscribe((data) => console.log(data.action))
events.publish({ userId: "123", action: "login" })
events.publish({ userId: "123", action: "logout" })
unsubscribe() // Critical: prevents memory leaks

The browser’s EventTarget provides native pub/sub via CustomEvent:

dom-event-target.ts
2 collapsed lines
// Publisher
const event = new CustomEvent("user:login", { detail: { userId: "123" } })
document.dispatchEvent(event)
// Subscriber
document.addEventListener("user:login", (e: CustomEvent) => {
console.log(e.detail.userId)
1 collapsed line
})

Limitations:

  • Main thread only—doesn’t work in Web Workers or Node.js
  • No TypeScript genericsdetail is any
  • Global namespace—all events share document, risking collisions

Custom implementation adds ~16 lines but works across all JavaScript runtimes.

Production systems require: topic-based routing, error isolation, async support, and proper typing. Key design decisions explained inline:

production-pub-sub.ts
8 collapsed lines
type Callback<T = unknown> = (data: T) => void | Promise<void>
interface Subscription {
token: number
unsubscribe: () => void
}
// Using Map<string, Map<number, Callback>> for:
// - O(1) topic lookup
// - O(1) subscriber add/remove by token
// - Stable iteration order (subscribers called in registration order)
class PubSub {
private topics = new Map<string, Map<number, Callback>>()
private nextToken = 0
subscribe<T>(topic: string, callback: Callback<T>): Subscription {
const token = this.nextToken++
if (!this.topics.has(topic)) {
this.topics.set(topic, new Map())
}
this.topics.get(topic)!.set(token, callback as Callback)
return {
token,
unsubscribe: () => {
const subscribers = this.topics.get(topic)
if (subscribers) {
subscribers.delete(token)
// Clean up empty topics to prevent memory growth
if (subscribers.size === 0) this.topics.delete(topic)
}
},
}
}
// Synchronous publish with error isolation
// Returns: whether any subscribers existed
publish<T>(topic: string, data: T): boolean {
const subscribers = this.topics.get(topic)
if (!subscribers || subscribers.size === 0) return false
for (const callback of subscribers.values()) {
// Critical: try/catch per subscriber
// One failing subscriber must not break others
try {
callback(data)
} catch (error) {
console.error(`[PubSub] Error in subscriber for "${topic}":`, error)
// Optional: emit to error topic for centralized handling
// this.publish('pubsub:error', { topic, error, data })
}
}
return true
}
// Async publish: waits for all subscribers, doesn't short-circuit on errors
async publishAsync<T>(topic: string, data: T): Promise<PromiseSettledResult<void>[]> {
const subscribers = this.topics.get(topic)
if (!subscribers || subscribers.size === 0) return []
// Promise.allSettled (ES2020) ensures all subscribers complete
// even if some reject—critical for reliable event handling
const promises = Array.from(subscribers.values()).map(async (callback) => {
await callback(data)
})
return Promise.allSettled(promises)
}
}
2 collapsed lines
// Singleton for cross-module communication
export const pubsub = new PubSub()

Design Decisions:

ChoiceRationale
Map<string, Map<number, Callback>>Nested maps give O(1) operations at both topic and subscriber level
Numeric tokensMonotonic IDs avoid collision; simpler than UUID
Promise.allSettled over Promise.allDoesn’t short-circuit on first rejection—all subscribers complete
Empty topic cleanupPrevents unbounded memory growth from stale topics
Per-subscriber try/catchIsolates failures; one bad subscriber doesn’t break others

Memory leaks in pub/sub arise when subscribers outlive their intended scope. Common patterns:

  1. Anonymous functions can’t be removedremoveEventListener requires the same function reference
  2. Closures capture component state — subscriber holds references preventing garbage collection
  3. Missing cleanup on unmount — React components, Angular services, etc.

Node.js warns at 11+ listeners per event: MaxListenersExceededWarning: Possible EventEmitter memory leak detected.

React Pattern (useEffect cleanup):

react-usage.tsx
4 collapsed lines
import { useEffect, useState } from "react"
import { pubsub } from "./pubsub"
interface StatusEvent {
userId: string
isOnline: boolean
}
function UserStatus({ userId }: { userId: string }) {
const [isOnline, setIsOnline] = useState(false)
useEffect(() => {
const { unsubscribe } = pubsub.subscribe<StatusEvent>("user:status", (data) => {
if (data.userId === userId) setIsOnline(data.isOnline)
})
// Critical: cleanup on unmount or userId change
return unsubscribe
}, [userId])
return <span>{isOnline ? "Online" : "Offline"}</span>
}

Key points:

  • Return unsubscribe directly from useEffect — it’s already a cleanup function
  • Include userId in deps array — re-subscribes when prop changes
  • Named function isn’t needed since we use the returned unsubscribe

Are subscribers called in registration order? It depends on the implementation.

Per ECMA-262, Map and Set iterate in insertion order. Our implementation using Map<number, Callback> guarantees subscribers execute in registration order.

Per Node.js EventEmitter docs: “All listeners attached to it at the time of emitting are called in order.”

Per WHATWG DOM Standard: EventTarget listeners are called in registration order within each phase.

Caveat: Not all libraries guarantee order. If order matters for your use case, either:

  1. Use a single subscriber that orchestrates the sequence
  2. Verify your library’s implementation

Node.js docs warn: “Using async functions with event handlers is problematic, because it can lead to an unhandled rejection in case of a thrown exception.”

async-pitfall.ts
// Dangerous: unhandled rejection if async handler throws
pubsub.subscribe("data", async (data) => {
await saveToDatabase(data) // If this throws, rejection is unhandled
})

Solutions:

  1. Use publishAsync with Promise.allSettled (shown in production implementation)
  2. Wrap async subscribers in try/catch:
safe-async-subscriber.ts
pubsub.subscribe("data", async (data) => {
try {
await saveToDatabase(data)
} catch (error) {
// Handle locally or emit to error topic
pubsub.publish("error", { source: "data", error })
}
})

Topic naming convention: domain.entity.action (e.g., user.profile.updated, cart.item.added).

Wildcard types (MQTT convention):

  • * — matches exactly one segment: user.*.login matches user.123.login
  • # — matches zero or more segments (must be last): user.# matches user, user.123, user.123.login
wildcard-matching.ts
3 collapsed lines
// Matches subscription patterns like "user.*.login" or "user.#"
// against published topics like "user.123.login"
function topicMatches(pattern: string, topic: string): boolean {
const patternParts = pattern.split(".")
const topicParts = topic.split(".")
for (let i = 0; i < patternParts.length; i++) {
const p = patternParts[i]
if (p === "#") return true // Multi-level: match rest
if (p !== "*" && p !== topicParts[i]) return false
}
return patternParts.length === topicParts.length
}

Standard pub/sub has no built-in backpressure. Publishers emit as fast as they can regardless of subscriber capacity.

Strategies:

  • Debounce/throttle at publish — lossy but prevents flooding
  • Buffer with limits — accumulate events, drop oldest when full
  • Async iterator with highWater — libraries like event-iterator provide backpressure signals

For high-throughput systems, consider message queues (RabbitMQ, Redis Streams) with explicit acknowledgment.

Understanding when to avoid pub/sub is as important as knowing when to use it.

Per CodeOpinion: “Commands do not use the publish-subscribe pattern. Trying to force everything into publish-subscribe when that’s not the pattern you want will lead you to apply more patterns incorrectly.”

Command vs Event:

  • Command: “CreateOrder” — directed to a specific handler, expects execution
  • Event: “OrderCreated” — notification of something that happened, no expectation of specific handler

If your publisher expects a specific response event back, you’ve recreated synchronous RPC with extra complexity. Use direct function calls or actual RPC instead.

CustomerChanged doesn’t indicate why something changed. Consumers must diff the data to infer intent. Prefer intent-revealing events: CustomerAddressUpdated, CustomerDeactivated.

Pub/sub adds indirection. For a small app with straightforward component communication, direct function calls or context/props are simpler and more debuggable.

In-process pub/sub doesn’t guarantee delivery. If no subscriber exists when an event fires, it’s lost. For critical events, use message queues with persistence (Redis Streams, RabbitMQ, Kafka).

Martin Fowler: “It can become problematic if there really is a logical flow that runs over various event notifications. The problem is that it can be hard to see such a flow as it’s not explicit in any program text.”

The implicit control flow that provides loose coupling also makes debugging harder. Distributed tracing and careful logging are essential for production pub/sub systems.

Pub/sub solves “prop drilling” when unrelated components need to react to the same events.

E-commerce cart example:

  • ProductCard publishes cart.item.added on button click
  • CartIcon subscribes → updates badge count
  • Toast subscribes → shows confirmation
  • Analytics subscribes → tracks conversion

Each subscriber is independent. Adding a new reaction requires no changes to ProductCard.

External brokers (Redis Pub/Sub, RabbitMQ, Google Cloud Pub/Sub, Kafka) provide distributed pub/sub with durability.

User registration flow:

  • UserService publishes user.created
  • EmailService → sends welcome email
  • AnalyticsService → tracks signup metrics
  • OnboardingService → queues tutorial sequence

Services deploy independently. Adding a new reaction (e.g., CRM sync) requires no changes to UserService.

For production, prefer battle-tested libraries unless you need custom semantics.

LibrarySizeWildcardsTypeScriptAPIMaintained
mitt200B* (all events)Yeson(), off(), emit()Yes (11k+ stars)
nanoevents107BNoYesReturns unbind from on()Yes
EventEmitter31.5KBNoYesNode.js-compatibleYes
EventEmitter2LargerYes (*, **)YesExtended EE APIYes

Recommendations:

  • Minimal footprint: mitt or nanoevents (both under 200B)
  • Node.js API compatibility: EventEmitter3
  • Wildcards needed: EventEmitter2 (or MQTT/AMQP for distributed)
  • Learning: Build your own—the 20-line implementation teaches the core

mitt caveat: The * handler receives all events but is a listener, not a wildcard pattern. Publishing to * directly causes double-triggering issues.

PracticeWhy
Return unsubscribe functionEnables cleanup; prevents memory leaks
Use Map/Set not plain objectsO(1) operations, guaranteed iteration order
try/catch per subscriberIsolates failures; one bad handler doesn’t break others
Promise.allSettled for asyncWaits for all handlers; doesn’t short-circuit on rejection
Clean up empty topicsPrevents unbounded memory growth
Hierarchical topic namesdomain.entity.action enables wildcards, organization
  1. Per-subscriber isolation: Wrap each callback in try/catch (shown in production implementation)
  2. Error topic: Emit to pubsub:error for centralized handling
  3. Dead-letter queue: For distributed systems, track repeatedly failing messages
  4. Dev mode exceptions: Optionally rethrow in development for stack traces

Pub/Sub trades explicit control flow for decoupling. Publishers emit without knowing receivers; subscribers react without knowing sources. This enables independent component evolution and many-to-many communication at the cost of implicit, harder-to-trace control flow.

Use pub/sub when:

  • Multiple independent components need to react to the same events
  • Components should evolve independently (add/remove reactions without changing publishers)
  • You’re building event-driven architecture (frontend cross-component communication, backend microservices)

Avoid pub/sub when:

  • You need request-response semantics
  • There’s a single known recipient (use Observer or direct calls)
  • Delivery guarantees are required (use message queues)
  • The system is simple and unlikely to need the decoupling

Implementation is straightforward: Map<string, Set<Function>>, return unsubscribe, try/catch per subscriber. The challenge is architectural—knowing when the indirection is worth the debuggability cost.

  • JavaScript ES6+ (Map, Set, Promise, async/await)
  • Basic understanding of event-driven programming
  • Familiarity with React hooks (for framework examples)
TermDefinition
PublisherComponent that emits events; unaware of subscribers
SubscriberComponent that registers callbacks for events; unaware of publishers
Broker / Event BusIntermediary that routes events from publishers to subscribers
Topic / ChannelNamed category for events; subscribers register interest by topic
BackpressureMechanism for consumers to signal producers to slow down
  • Pub/Sub provides three-dimensional decoupling: space, time, synchronization
  • Use Map<string, Set<Function>> for O(1) operations and guaranteed order
  • Always return unsubscribe function; tie cleanup to component lifecycle
  • Isolate subscriber errors with per-callback try/catch
  • Use Promise.allSettled for async publish to avoid short-circuiting
  • Avoid for request-response, single recipients, or when delivery guarantees matter
  • Libraries: mitt (200B), nanoevents (107B) for minimal footprint

Specifications & Standards:

Official Documentation:

Libraries:

Architectural Guidance:

Read more