JavaScript Design Patterns: Singleton, Factory, Module, Observer, Proxy and Strategy
Key takeaways
Several classic patterns shrink in JavaScript because ES modules already behave like singletons and closures replace much of the class ceremony. The post implements each pattern, shows where it fits in practice, and flags pitfalls like a listener that off() cannot remove because a different function reference was passed.
Introduction
What are Design Patterns?
Design Patterns are proven solution templates with names for recurring design problems. They help teams communicate efficiently with phrases like “let’s use a factory.”
This post walks through six patterns that come up often in JavaScript code, Singleton, Factory, Module, Observer, Proxy and Strategy, each with a small implementation and, where it matters, the modern ES6 alternative (module-level singletons, private class fields, the built-in Proxy). It ends with a minimal subscribe/notify store that combines several of them.
One thing worth keeping in mind throughout: most of the classic pattern catalog was written for languages like C++ and Java, where functions are not values and every behavior has to live inside a class. JavaScript has first-class functions, closures, object literals and an ES module system, so several patterns collapse into a line or two of idiomatic code. The class-based versions below are still useful for recognizing the shape of a pattern in someone else’s code, but for each one I also point out the lighter JavaScript-native form and the pitfalls that show up when these patterns meet real applications.
Singleton Pattern
Concept
Creates only one instance and makes it globally accessible.
Implementation
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = null;
Database.instance = this;
}
connect() {
if (!this.connection) {
this.connection = "DB Connected";
console.log(this.connection);
}
return this.connection;
}
}
// Usage
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true (same instance)
db1.connect(); // DB Connected
db2.connect(); // (already connected)
The trick here is that a JavaScript constructor may return an object, and when it does, new hands back that object instead of the freshly created this. The first call stores itself on the static Database.instance; every later call returns the stored instance. It works, but it is surprising to readers: new normally means “give me a new object”, and here it silently does not. Anyone can also bypass it by assigning Database.instance = null, and subclasses inherit the same static slot, so class TestDatabase extends Database would get the parent’s instance back.
Modern Approach (ES6 Module)
// database.js
class Database {
constructor() {
this.connection = null;
}
connect() {
if (!this.connection) {
this.connection = "DB Connected";
}
return this.connection;
}
}
export default new Database();
// main.js
import db from './database.js';
db.connect();
This version relies on a guarantee of the ES module system: a module is evaluated once per module graph, and every import of the same specifier receives the same exported binding. So export default new Database() produces one shared object without any special constructor logic. It is the idiomatic JavaScript singleton, and it is what most libraries do.
The guarantee has limits that tend to show up in production rather than in tutorials. “Once per module graph” is not “once per process”: if a bundler ends up including two copies of the same package (for example, two different versions pulled in by different dependencies), you get two singletons, and code that expects shared state quietly talks to different objects. Worker threads and iframes each have their own module graph, and hot module replacement during development can re-evaluate the module and create a fresh instance while old references are still alive. Tests suffer the most: because the instance survives between test cases in the same run, state leaks from one test into the next. A common compromise is to export a factory (createDatabase()) for tests and wiring, and a default instance for application code, so the “only one” rule is a convention of the app rather than something baked into the class.
Factory Pattern
Concept
Encapsulates object creation logic for flexible object instantiation.
Implementation
class User {
constructor(name, role) {
this.name = name;
this.role = role;
}
getPermissions() {
return [];
}
}
class Admin extends User {
getPermissions() {
return ['read', 'write', 'delete'];
}
}
class Guest extends User {
getPermissions() {
return ['read'];
}
}
class Member extends User {
getPermissions() {
return ['read', 'write'];
}
}
// Factory
class UserFactory {
static createUser(name, role) {
switch (role) {
case 'admin':
return new Admin(name, role);
case 'member':
return new Member(name, role);
case 'guest':
return new Guest(name, role);
default:
throw new Error(`Unknown role: ${role}`);
}
}
}
// Usage
const admin = UserFactory.createUser("John", "admin");
console.log(admin.getPermissions()); // ['read', 'write', 'delete']
const guest = UserFactory.createUser("Guest", "guest");
console.log(guest.getPermissions()); // ['read']
The value of the factory is that callers only know a string such as "admin", which is usually what arrives from a database row, a JWT claim or a form field, and never have to import or name the concrete classes. When you add a Moderator role, one case changes and every call site keeps working. Throwing on an unknown role is deliberate: silently returning a default User would give an unknown role an empty permission list at best, or a wrong one if the default is ever changed.
In JavaScript the switch inside a class with a single static method is more ceremony than needed. A plain lookup table does the same job and makes the set of roles data rather than code:
const roles = { admin: Admin, member: Member, guest: Guest }; followed by const Cls = roles[role]; if (!Cls) throw new Error(...); return new Cls(name, role);.
One trap with the table version is using a normal object as the map: roles["constructor"] or roles["__proto__"] resolve to inherited properties instead of undefined, so an attacker-controlled role string can reach code you did not intend. Using Object.hasOwn(roles, role) for the check, a Map, or Object.create(null) for the table avoids that. Also note that the permission lists here are recreated on every call; if they were shared constant arrays, any caller that pushed into the returned array would change permissions for every user of that role.
Module Pattern
Concept
Uses encapsulation to distinguish between private variables and public methods.
Implementation (IIFE)
const Counter = (function() {
// Private variable
let count = 0;
// Private function
function log() {
console.log(`Current count: ${count}`);
}
// Public API
return {
increment() {
count++;
log();
},
decrement() {
count--;
log();
},
getCount() {
return count;
}
};
})();
// Usage
Counter.increment(); // Current count: 1
Counter.increment(); // Current count: 2
console.log(Counter.getCount()); // 2
console.log(Counter.count); // undefined (private)
The IIFE (immediately invoked function expression) creates a function scope, runs it once, and returns an object whose methods close over count and log. Nothing outside can reach those variables because there is no reference to them outside the closure. Before ES modules and class private fields, this was the only way to get real privacy in JavaScript, and it is why older libraries such as jQuery plugins are wrapped in (function() { ... })(). Today an ES module gives you the same effect for free: anything a module does not export is private to that file, so a module-level let count = 0 plus exported functions is the modern equivalent of this pattern.
Modern Approach (ES6 Class)
class Counter {
#count = 0; // Private field
increment() {
this.#count++;
this.#log();
}
decrement() {
this.#count--;
this.#log();
}
getCount() {
return this.#count;
}
#log() {
console.log(`Current count: ${this.#count}`);
}
}
const counter = new Counter();
counter.increment(); // Current count: 1
console.log(counter.getCount()); // 1
// console.log(counter.#count); // SyntaxError (private)
#count is a hard private field: it is enforced by the engine, it does not show up in Object.keys or JSON.stringify, and trying to access it from outside the class body is a syntax error at parse time, not a runtime undefined. That is stronger than the old convention of prefixing names with an underscore, which was only a hint. The trade-offs are practical ones. Private fields are per class, so a subclass cannot read its parent’s #count, and a Proxy wrapped around an instance breaks methods that touch private fields, because the proxy object itself does not have the field (TypeError: Cannot read private member #count from an object whose class did not declare it). Libraries that rely on proxies for reactivity, such as Vue’s reactive(), run into exactly this, which is why they recommend avoiding #private fields on objects you intend to make reactive.
Observer Pattern
Concept
Notifies subscribers of object state changes.
Implementation
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received notification:`, data);
}
}
// Usage
const subject = new Subject();
const observer1 = new Observer("Observer1");
const observer2 = new Observer("Observer2");
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify("New data!");
// Observer1 received notification: New data!
// Observer2 received notification: New data!
subject.unsubscribe(observer1);
subject.notify("Another data");
// Observer2 received notification: Another data
The subject only knows that each observer has an update method; it knows nothing about what observers do with the data. That is the loose coupling the pattern is famous for: you can add a logger, a UI updater and an analytics sink without touching the subject. The flip side is that control flow becomes implicit. When notify is called, you cannot tell from that line which code will run, in what order, or whether one of the observers will throw. In this implementation, an exception thrown by observer1.update stops the forEach, so observer2 never hears about the change. If observers are independent, wrap each call in try/catch so one faulty subscriber cannot silence the rest.
Real-world Example: Event System
class EventEmitter {
constructor() {
this.events = {};
}
on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}
off(event, listener) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(l => l !== listener);
}
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(listener => listener(data));
}
once(event, listener) {
const wrapper = (data) => {
listener(data);
this.off(event, wrapper);
};
this.on(event, wrapper);
}
}
// Usage
const emitter = new EventEmitter();
function onUserLogin(user) {
console.log(`${user.name} logged in`);
}
emitter.on('login', onUserLogin);
emitter.on('login', (user) => {
console.log(`Welcome, ${user.name}!`);
});
emitter.emit('login', { name: 'John' });
// John logged in
// Welcome, John!
// once: Execute only once
emitter.once('logout', (user) => {
console.log(`${user.name} logged out`);
});
emitter.emit('logout', { name: 'John' }); // John logged out
emitter.emit('logout', { name: 'John' }); // (not executed)
This is a stripped-down version of Node’s built-in EventEmitter and of the browser’s EventTarget, and in real code you would normally use one of those rather than writing your own. Two details in this implementation are easy to get wrong when you do write one. First, once removes the wrapper while emit is iterating. That is safe here only because off builds a new array with filter, so the forEach in progress keeps walking the old array; an implementation that removed items in place with splice would skip the listener right after the removed one. Second, emit calls listeners synchronously, so a slow listener blocks the emitter and every listener after it.
The problem I see most often with event emitters in long-lived applications is listeners that are never removed. Each on call keeps a reference to the listener function and, through its closure, to everything it captured, often a whole component or DOM subtree. In a single-page app, a component that subscribes on mount and forgets to unsubscribe on unmount leaks one listener every time the user navigates back to that screen, and events start firing multiple times per action. Node’s EventEmitter prints MaxListenersExceededWarning: Possible EventEmitter memory leak detected after 10 listeners on one event for exactly this reason; that warning is almost always a real leak, not a limit to raise. Returning an unsubscribe function from on, as the Store example at the end does, makes cleanup much harder to forget.
Proxy Pattern
Concept
Controls access to objects or provides additional functionality.
Implementation (ES6 Proxy)
const user = {
name: "John",
age: 25,
email: "[email protected]"
};
const handler = {
get(target, prop) {
console.log(`Reading ${prop}`);
return target[prop];
},
set(target, prop, value) {
console.log(`Setting ${prop} to ${value}`);
// Validation
if (prop === 'age' && typeof value !== 'number') {
throw new TypeError("Age must be a number");
}
target[prop] = value;
return true;
}
};
const proxyUser = new Proxy(user, handler);
console.log(proxyUser.name); // Reading name -> John
proxyUser.age = 26; // Setting age to 26
// proxyUser.age = "26"; // TypeError
A Proxy wraps a target object and routes fundamental operations (property reads, writes, in checks, deletes, function calls) through trap functions on the handler. Operations with no trap fall through to the target unchanged. This is what makes it more powerful than the classic object-oriented Proxy pattern: you do not have to re-declare every method of the target, and you can intercept operations like “a property that does not exist was read”, which is how validation libraries and ORMs implement dynamic fields and how Vue 3 tracks which properties a component reads.
The set trap must return true to signal success; in strict mode (which includes all ES modules and classes) returning false or nothing throws a TypeError. Writing target[prop] = value works for plain objects, but the more robust form is return Reflect.set(target, prop, value, receiver), which preserves correct behavior for setters and inherited properties. Keep in mind that the validation only applies to writes that go through the proxy: code that still holds a reference to the original user object can set age to a string without any check. Proxies also add overhead to every intercepted operation, so they are a good fit for configuration objects and API boundaries, and a poor fit for objects in a hot loop.
Real-world Example: Caching Proxy
function createCachedFunction(fn) {
const cache = new Map();
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log("Returning from cache");
return cache.get(key);
}
console.log("Computing...");
const result = target.apply(thisArg, args);
cache.set(key, result);
return result;
}
});
}
// Usage
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const cachedFib = createCachedFunction(fibonacci);
console.log(cachedFib(10)); // Computing... -> 55
console.log(cachedFib(10)); // Returning from cache -> 55
The apply trap intercepts calls to a function, which makes it a neat way to add memoization without editing the original. But this example has a subtle flaw that is worth understanding: inside fibonacci, the recursive calls go to fibonacci itself, not to cachedFib. So the first cachedFib(10) still performs the full exponential recursion (177 calls for n = 10), and only the top-level result is cached. cachedFib(40) would still take seconds the first time. For the cache to help recursion, the recursive calls must go through the cached function, for example by defining const fib = createCachedFunction(n => n <= 1 ? n : fib(n - 1) + fib(n - 2));. With that change each value is computed once and the cost drops to linear.
JSON.stringify(args) as a cache key also has limits. It cannot tell undefined from null inside arrays, it drops functions and symbols, it throws on circular structures and on BigInt, and {a: 1, b: 2} and {b: 2, a: 1} produce different keys for what is logically the same argument. For functions that take primitives it is fine; for object arguments, consider keying on an explicit ID. Finally, this cache never evicts entries, so memoizing a function that is called with many distinct arguments is a slow memory leak; a size-bounded LRU or a WeakMap keyed by object arguments avoids that.
Strategy Pattern
Concept
Encapsulates algorithms to allow runtime selection.
Implementation
// Strategies
class CreditCardStrategy {
pay(amount) {
console.log(`Paying ${amount} with Credit Card`);
}
}
class PayPalStrategy {
pay(amount) {
console.log(`Paying ${amount} with PayPal`);
}
}
class CryptoStrategy {
pay(amount) {
console.log(`Paying ${amount} with Cryptocurrency`);
}
}
// Context
class PaymentContext {
constructor(strategy) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
executePayment(amount) {
this.strategy.pay(amount);
}
}
// Usage
const payment = new PaymentContext(new CreditCardStrategy());
payment.executePayment(10000); // Paying 10000 with Credit Card
payment.setStrategy(new PayPalStrategy());
payment.executePayment(20000); // Paying 20000 with PayPal
Strategy replaces a growing if (method === 'card') ... else if (method === 'paypal') ... chain with interchangeable objects that share one interface, so adding a payment method means adding one strategy rather than editing the context. In JavaScript, a strategy with a single method is really just a function, so the classes can be replaced by const strategies = { card: amount => ..., paypal: amount => ... } and strategies[method](amount). Array methods already work this way: the comparator you pass to sort is a strategy.
Two practical notes. The context here calls pay synchronously and ignores its result, but real payment providers are asynchronous and can fail, so a real strategy interface would return a promise and the context would await it and handle rejection. And setStrategy makes the context stateful: if two parts of the program share one PaymentContext and one of them switches the strategy, the other silently starts paying with the new method. Passing the strategy per call, or creating a context per checkout, avoids that shared mutable state.
Real-world Example: State Management
class Store {
constructor(initialState = {}) {
this.state = initialState;
this.listeners = [];
}
getState() {
return this.state;
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.notify();
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notify() {
this.listeners.forEach(listener => listener(this.state));
}
}
// Usage
const store = new Store({ count: 0, user: null });
const unsubscribe = store.subscribe((state) => {
console.log("State changed:", state);
});
store.setState({ count: 1 });
// State changed: { count: 1, user: null }
store.setState({ user: { name: "John" } });
// State changed: { count: 1, user: { name: "John" } }
unsubscribe(); // Unsubscribe
This small store combines three of the patterns above: it is usually exported as a module-level singleton, it is an observer subject, and subscribe returns an unsubscribe closure instead of requiring an off call with the same function reference. That last design choice is the same one Redux and Zustand make, and it solves the listener-removal problem described in the FAQ below.
The most important line is this.state = { ...this.state, ...newState }. It creates a new state object on every update instead of mutating the old one, so a subscriber can detect a change with a cheap reference comparison (prev !== next), which is what React’s useSyncExternalStore relies on. The merge is shallow, though: setState({ user: { name: "John" } }) replaces the whole user object, and nested updates must spread each level themselves. The biggest hole is that getState() returns the live object, so a caller can write store.getState().count = 5, which changes the state without notifying anyone and breaks the reference-comparison assumption. Freezing the state in development (Object.freeze) or only exposing copies catches that class of bug early. Also note that notify runs listeners synchronously after every setState, so three updates in a row trigger three rounds of notifications; real stores batch updates for that reason.
Choosing the lightest version of a pattern
Patterns are vocabulary, not goals. In JavaScript, the lightest version is usually the right one: an ES module instead of a Singleton class, a lookup table instead of a Factory class, a function instead of a Strategy class, and EventTarget or Node’s EventEmitter instead of a hand-written Observer. Reach for the heavier class-based forms when you need what they add, such as inheritance hierarchies or several related methods per strategy, and watch for the runtime issues these patterns share: shared state leaking between tests, listeners that are never removed, and caches that never shrink.
Frequently Asked Questions (FAQ)
Q. Why doesn’t emitter.off() remove my listener?
A. The off method in the EventEmitter example filters listeners by reference (l !== listener), so you must pass the exact same function object you gave to on. The anonymous Welcome arrow function in the example can never be removed, because a new arrow function with the same body is a different object. The same applies to once: it registers an internal wrapper, so calling off with the original listener does nothing. Keep a named reference to any listener you need to remove; listeners that are never removed also keep their closures alive, which is a common source of memory leaks in long-lived pages.
Related Articles
- JavaScript Classes
- JavaScript Modules
- The Observer Pattern in C++
- Python Decorators
- TypeScript Getting Started
- JavaScript Getting Started