JavaScript Observer Pattern: Complete Guide
A complete guide to the JavaScript observer pattern. Covers subject and observer implementation, EventEmitter patterns, pub/sub architecture, typed events, wildcard subscriptions, and decoupling components with event-driven design.
The observer pattern defines a one-to-many relationship between objects: when a subject changes state, all registered observers are notified automatically. This pattern is fundamental to event-driven JavaScript and powers everything from DOM events to reactive frameworks.
For building reactive UIs with observers, see Building a Reactive UI with the JS Observer.
Classic Subject/Observer Implementation
class Subject {
#observers = new Map();
#state = {};
subscribe(event, observer) {
if (!this.#observers.has(event)) {
this.#observers.set(event, new Set());
}
this.#observers.get(event).add(observer);
// Return unsubscribe function
return () => {
this.#observers.get(event)?.delete(observer);
};
}
notify(event, data) {
const observers = this.#observers.get(event);
if (!observers) return;
for (const observer of observers) {
try {
observer.update(event, data);
} catch (error) {
console.error(`Observer error on "${event}":`, error);
}
}
}
getObserverCount(event) {
return this.#observers.get(event)?.size || 0;
}
}
class Observer {
#name;
#handler;
constructor(name, handler) {
this.#name = name;
this.#handler = handler;
}
update(event, data) {
this.#handler(event, data, this.#name);
}
}
// Usage
const store = new Subject();
const logger = new Observer("Logger", (event, data) => {
console.log(`[LOG] ${event}:`, data);
});
const analytics = new Observer("Analytics", (event, data) => {
console.log(`[ANALYTICS] Tracking: ${event}`);
});
const unsub1 = store.subscribe("user:login", logger);
const unsub2 = store.subscribe("user:login", analytics);
store.notify("user:login", { userId: "abc123", timestamp: Date.now() });
// Cleanup
unsub1();
console.log(store.getObserverCount("user:login")); // 1Function-Based EventEmitter
function createEventEmitter() {
const listeners = new Map();
const onceListeners = new Map();
let maxListeners = 10;
function checkMaxListeners(event) {
const count = (listeners.get(event)?.length || 0) +
(onceListeners.get(event)?.length || 0);
if (count > maxListeners) {
console.warn(
`Possible memory leak: ${count} listeners for "${event}" (max: ${maxListeners})`
);
}
}
return {
on(event, handler) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(handler);
checkMaxListeners(event);
return this;
},
once(event, handler) {
if (!onceListeners.has(event)) onceListeners.set(event, []);
onceListeners.get(event).push(handler);
return this;
},
off(event, handler) {
const handlers = listeners.get(event);
if (handlers) {
const idx = handlers.indexOf(handler);
if (idx > -1) handlers.splice(idx, 1);
}
return this;
},
emit(event, ...args) {
const handlers = listeners.get(event) || [];
handlers.forEach((h) => h(...args));
const onceHandlers = onceListeners.get(event) || [];
onceHandlers.forEach((h) => h(...args));
onceListeners.delete(event);
return handlers.length + onceHandlers.length > 0;
},
listenerCount(event) {
return (listeners.get(event)?.length || 0) +
(onceListeners.get(event)?.length || 0);
},
removeAllListeners(event) {
if (event) {
listeners.delete(event);
onceListeners.delete(event);
} else {
listeners.clear();
onceListeners.clear();
}
return this;
},
setMaxListeners(n) {
maxListeners = n;
return this;
},
eventNames() {
return [...new Set([...listeners.keys(), ...onceListeners.keys()])];
},
};
}
// Usage with chaining
const emitter = createEventEmitter();
emitter
.on("data", (chunk) => console.log("Received:", chunk))
.on("error", (err) => console.error("Error:", err))
.once("end", () => console.log("Stream ended"));
emitter.emit("data", { id: 1, value: "hello" });
emitter.emit("end");
emitter.emit("end"); // No handler fires (was once)Pub/Sub with Namespaces
class PubSub {
#channels = new Map();
#separator = ":";
subscribe(channel, handler) {
if (!this.#channels.has(channel)) {
this.#channels.set(channel, new Set());
}
this.#channels.get(channel).add(handler);
return {
unsubscribe: () => {
this.#channels.get(channel)?.delete(handler);
if (this.#channels.get(channel)?.size === 0) {
this.#channels.delete(channel);
}
},
};
}
subscribePattern(pattern, handler) {
const regex = new RegExp(
"^" + pattern.replace(/\*/g, "[^:]+").replace(/#/g, ".*") + "$"
);
const wrappedHandler = (data, meta) => {
handler(data, { ...meta, matchedPattern: pattern });
};
wrappedHandler._pattern = regex;
wrappedHandler._original = handler;
if (!this.#channels.has("__patterns__")) {
this.#channels.set("__patterns__", new Set());
}
this.#channels.get("__patterns__").add(wrappedHandler);
return {
unsubscribe: () => {
this.#channels.get("__patterns__")?.delete(wrappedHandler);
},
};
}
publish(channel, data) {
const meta = { channel, timestamp: Date.now() };
let delivered = 0;
// Exact subscribers
const handlers = this.#channels.get(channel);
if (handlers) {
for (const handler of handlers) {
handler(data, meta);
delivered++;
}
}
// Pattern subscribers
const patterns = this.#channels.get("__patterns__");
if (patterns) {
for (const handler of patterns) {
if (handler._pattern.test(channel)) {
handler(data, meta);
delivered++;
}
}
}
return delivered;
}
getChannels() {
return [...this.#channels.keys()].filter((k) => k !== "__patterns__");
}
}
// Usage
const pubsub = new PubSub();
// Exact subscription
pubsub.subscribe("orders:created", (order) => {
console.log("New order:", order.id);
});
// Wildcard: any event in "orders" namespace
pubsub.subscribePattern("orders:*", (data, meta) => {
console.log(`Orders event [${meta.channel}]:`, data);
});
// Deep wildcard: any user event at any depth
pubsub.subscribePattern("users:#", (data, meta) => {
console.log(`User event [${meta.channel}]:`, data);
});
pubsub.publish("orders:created", { id: "ORD-001", total: 59.99 });
pubsub.publish("users:profile:updated", { userId: "U1", name: "Alice" });Async Observer with Priority
class AsyncEventBus {
#handlers = new Map();
on(event, handler, priority = 0) {
if (!this.#handlers.has(event)) {
this.#handlers.set(event, []);
}
this.#handlers.get(event).push({ handler, priority });
this.#handlers
.get(event)
.sort((a, b) => b.priority - a.priority);
return () => {
const list = this.#handlers.get(event);
const idx = list.findIndex((h) => h.handler === handler);
if (idx > -1) list.splice(idx, 1);
};
}
async emit(event, data) {
const handlers = this.#handlers.get(event) || [];
const results = [];
let stopped = false;
for (const { handler } of handlers) {
if (stopped) break;
try {
const result = await handler(data, {
event,
stop() {
stopped = true;
},
});
results.push({ status: "fulfilled", value: result });
} catch (error) {
results.push({ status: "rejected", reason: error });
}
}
return results;
}
async emitParallel(event, data) {
const handlers = this.#handlers.get(event) || [];
return Promise.allSettled(
handlers.map(({ handler }) => handler(data, { event }))
);
}
}
// Usage
const bus = new AsyncEventBus();
// High priority: validation runs first
bus.on(
"order:submit",
async (order, ctx) => {
if (!order.items.length) {
ctx.stop(); // Prevent further handlers
throw new Error("Empty order");
}
return { validated: true };
},
100
);
// Normal priority: process order
bus.on("order:submit", async (order) => {
console.log("Processing order:", order.id);
return { processed: true };
}, 50);
// Low priority: send notification
bus.on("order:submit", async (order) => {
console.log("Sending notification for:", order.id);
}, 10);
const results = await bus.emit("order:submit", {
id: "ORD-002",
items: [{ sku: "A1", qty: 2 }],
});
console.log(results);| Observer Variant | Execution Model | Use Case | Complexity |
|---|---|---|---|
| Classic Subject/Observer | Synchronous | Simple state changes | Low |
| EventEmitter | Synchronous | Node.js-style events | Medium |
| Pub/Sub with patterns | Synchronous | Decoupled microservices | Medium |
| Async with priority | Sequential async | Middleware pipelines | High |
| Parallel async | Concurrent async | Independent side effects | Medium |
Rune AI
Key Insights
- Subject/Observer defines a one-to-many notification relationship: Subjects track observers and notify them on state changes, keeping both sides decoupled
- Return unsubscribe functions to prevent memory leaks: Every subscribe call should return a cleanup function that removes the listener
- Pub/Sub with pattern matching scales to large applications: Wildcard and namespace-based subscriptions decouple publishers from subscribers entirely
- Async observers with priority enable middleware-like pipelines: Prioritized sequential execution with stop propagation mirrors express middleware patterns
- Set maximum listener limits to detect leaked subscriptions: Warning when listener counts exceed a threshold catches forgotten cleanup early
Frequently Asked Questions
What is the difference between observer and pub/sub?
How do I prevent memory leaks with observers?
Should I use synchronous or async observers?
How does the observer pattern relate to JavaScript Promises and async/await?
Can observers cause infinite loops?
Conclusion
The observer pattern decouples event producers from consumers in JavaScript. The classic subject/observer works for simple state tracking. EventEmitter provides a familiar Node.js-style interface. Pub/sub with namespaces and wildcards scales to large applications. For building reactive user interfaces using these patterns, see Building a Reactive UI with the JS Observer. For understanding the module pattern that often wraps observer implementations, review Implementing the Revealing Module Pattern in JS.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.