Observer and Pub/Sub Patterns in JavaScript
The observer pattern is a fundamental design pattern that establishes a one-to-many dependency between objects. When one object changes state, all its dependents are automatically notified. The pattern appears throughout JavaScript, powering DOM event handlers, React’s state management, and Node.js’s EventEmitter. Its pub/sub variant adds an event broker for even looser coupling between components. Understanding both patterns deeply will make you a more effective JavaScript developer.
What is the observer pattern?
In the observer pattern, there are two main actors: the subject (also called the observable) and the observers. The subject maintains a list of observers and notifies them whenever something interesting happens. This creates a loose coupling between the subject and observers—they don’t need to know about each other explicitly.
The key benefits are decoupling and flexibility. You can add or remove observers at runtime without changing the subject’s code. This makes your code more maintainable and easier to extend.
Building a class-based observer
Let’s implement a simple observable class that allows observers to subscribe and receive updates:
class Observable {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
// Return unsubscribe function
return () => {
this.observers = this.observers.filter(obs => obs !== observer);
};
}
notify(data) {
this.observers.forEach(observer => observer(data));
}
}
// Usage example
const newsFeed = new Observable();
const subscriberA = (article) => {
console.log(`Subscriber A received: ${article.title}`);
};
const subscriberB = (article) => {
console.log(`Subscriber B received: ${article.title}`);
};
// Subscribe to updates
newsFeed.subscribe(subscriberA);
newsFeed.subscribe(subscriberB);
// Publish new article
newsFeed.notify({ title: "JavaScript Patterns", content: "..." });
// Unsubscribe when done
const unsubscribe = newsFeed.subscribe((article) => {
console.log(`One-time subscriber: ${article.title}`);
});
unsubscribe(); // Stop receiving updates
This implementation shows the core concepts: observers register themselves, the observable stores them, and when something happens, the observable iterates through all observers and calls their update functions.
The pub/sub pattern
Pub/Sub (Publish/Subscribe) is a variation of the observer pattern that adds an intermediary called the event bus or message broker. Instead of subscribing directly to a specific object, publishers emit events to named channels, and subscribers listen to those channels.
This additional layer of indirection provides even looser coupling. Publishers and subscribers don’t need to know about each other at all—they communicate entirely through the event bus.
Implementing a custom event emitter
Here’s a practical EventEmitter implementation that follows the pub/sub model:
class EventEmitter {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// Return unsubscribe function
return () => {
this.events[event] = this.events[event].filter(cb => cb !== callback);
};
}
emit(event, ...args) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(...args));
}
}
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
}
}
The class keeps events in a plain object keyed by channel name. on registers a callback and returns an unsubscribe function for cleanup. emit invokes every handler registered for a channel. once wraps a callback so it unregisters itself after the first invocation, and off removes a specific listener from a channel.
Here’s how the API looks in practice with named channels:
// Usage with named channels
const emitter = new EventEmitter();
// Subscribe to specific events
emitter.on('user:login', (user) => {
console.log(`${user.name} logged in`);
});
emitter.on('user:login', (user) => {
analytics.track('login', { userId: user.id });
});
emitter.on('user:logout', (user) => {
console.log(`${user.name} logged out`);
});
// Emit events
emitter.emit('user:login', { name: 'Alice', id: 1 });
emitter.emit('user:logout', { name: 'Alice', id: 1 });
This EventEmitter supports multiple handlers per event, one-time subscriptions with once(), and clean removal with off().
Real-world use cases
Event Systems
The most obvious use case is building custom event systems. When you’re coordinating multiple components that need to react to user actions or data changes, pub/sub provides a clean communication channel without tight coupling.
// A simple event bus for application-wide communication
const EventBus = new EventEmitter();
// Component A publishes
function saveUserData(user) {
database.save(user)
.then(() => EventBus.emit('user:saved', user))
.catch(err => EventBus.emit('user:error', err));
}
// Component B subscribes
EventBus.on('user:saved', () => {
ui.showNotification('User saved successfully');
router.navigate('/dashboard');
});
The event bus pattern scales to more than one-off notifications. When your application needs to track shared state across components, the same pub/sub mechanism can drive a lightweight state store. Instead of emitting isolated events, you emit a single change event every time the store updates, and any component that cares about state can subscribe.
State management
For medium-complexity applications, you can build a simple state management system using the observer pattern:
function createStore(initialState) {
const state = { ...initialState };
const emitter = new EventEmitter();
return {
getState() {
return { ...state };
},
setState(updater) {
const newState = typeof updater === 'function'
? updater(state)
: updater;
Object.assign(state, newState);
emitter.emit('change', state);
},
subscribe(callback) {
return emitter.on('change', callback);
}
};
}
// Using the store
const store = createStore({ count: 0, user: null });
store.subscribe((state) => {
console.log('State changed:', state);
});
store.setState({ count: 1 });
store.setState((prev) => ({ count: prev.count + 1 }));
This createStore wrapper gives you a central state container with a subscription mechanism. You read state immutably with getState, update it with setState, and components that call subscribe are notified on every change. The pattern is deliberately simple, but it mirrors the core idea in Redux, Zustand, and other state libraries — a single source of truth that pushes updates to anyone listening.
State is one side of the coin. The other side is data arriving from outside the application, like API responses. Pub/sub decouples the fetch logic from the UI so each piece can change independently.
Decoupling API Calls from UI
When your application has multiple components that need to react to data fetched from an API, pub/sub prevents the API layer from knowing about your UI components:
const API = {
async fetchUsers() {
const users = await fetch('/api/users').then(r => r.json());
EventBus.emit('users:loaded', users);
return users;
}
};
// Any component can subscribe without modifying API
const UserList = {
init() {
EventBus.on('users:loaded', (users) => this.render(users));
},
render(users) {
console.log('Rendering', users.length, 'users');
}
};
When to use each pattern
Choose the basic observer pattern when you have a clear subject-observer relationship and observers need to react to a specific object’s state. Use pub/sub when you need multiple publishers and subscribers to communicate through a central hub, or when you want to completely decouple components from each other.
Both patterns are essential tools in your JavaScript toolkit. They appear everywhere in modern frameworks—React’s component lifecycle events, Vue’s reactivity system, Node.js streams, and browser DOM events all use variations of these patterns under the hood.
Choosing the right shape
The observer pattern works best when one object owns the state and others only need to know when that state changes. That makes it a strong fit for form controls, dashboards, and background tasks that report progress. The subject does not need to know who is listening or why. It only needs a stable way to publish updates.
That separation keeps dependencies small. A listener can be swapped out without rewriting the publisher, and a new listener can be added without changing the original state object. If the subject starts taking on business rules for every observer, the design is getting too tight. At that point, the pattern should be trimmed back so the central object stays focused on its own job.
Avoiding leaks and surprise work
Observers should be attached and removed with care. If a listener stays registered after the part of the app that created it is gone, the subject may keep calling code that no longer matters. That can waste memory and create confusing behavior when old callbacks keep firing. A good habit is to pair subscription setup with a clear cleanup path.
It also helps to keep observer callbacks small. A callback that does too much work can make an update feel slow, especially if several observers fire at once. If a listener needs to do heavier processing, let it schedule that work separately and keep the notification path quick. Fast notifications are easier to reason about and less likely to block the rest of the app.
Observer or pub/sub
Observer and pub/sub often look similar, but they solve slightly different problems. Observer is usually about a direct relationship between one subject and its listeners. Pub/sub adds an extra broker so publishers and subscribers do not need to know about each other at all. That extra layer is useful when many parts of the system communicate indirectly.
If the relationship is simple, the direct form is easier to follow. If the app needs looser coupling or cross-cutting events, a broker can help keep the edges clean. The key is to match the pattern to the amount of coordination your feature really needs.
Event Pressure
One thing to watch in observer-heavy code is event pressure. If the subject changes often, it may notify listeners more frequently than they really need. That can create work in places that do not actually need every tiny update. In those cases, throttling, batching, or simple guard logic can keep the notification stream more manageable.
It is also worth thinking about what each observer does with the update. A listener that only redraws part of a UI can usually react quickly, while a listener that performs a larger calculation may need a separate queue. Matching the observer to the cost of the reaction keeps the pattern useful instead of noisy.
Listener Management
Listeners should be treated as part of the component life cycle, not as fire-and-forget helpers. If a listener is attached when a view is created, there should be a clear point where it is removed again. That keeps stale callbacks from firing after the UI has moved on.
The cleanup habit also keeps the observer pattern from feeling heavy. When listeners are easy to add and easy to remove, the pattern stays flexible. That flexibility is one of the main reasons the pattern keeps showing up in browser code, event systems, and reactive libraries.