Creational Factory Singleton Structural Module Facade Behavioral Observer Strategy Design Patterns Create Objects Compose Communicate
Frontend

JavaScript Design Patterns Every Developer Should Know

Mayur Dabhi
Mayur Dabhi
April 4, 2026
22 min read

You open a JavaScript codebase and find three different ways of creating API clients, a config object that gets imported and mutated from a dozen files, and a payment function that's one long if/else chain with a new branch for every provider. None of it is broken, exactly. It's just hard to change without breaking something else. JavaScript lets you solve the same problem in many ways, and that freedom is how codebases end up like this.

Design patterns give names to the handful of approaches that tend to hold up. This post covers seven I actually see in day-to-day JavaScript: Singleton, Factory, Module, Observer, Strategy, Decorator and Proxy. For each one you'll get working code, where it shows up in libraries you already use, and the trade-offs that come with it.

Why Bother Learning Them?
  • Shared vocabulary: "Make it a strategy" is a faster code review comment than a paragraph of explanation
  • Reading other people's code: React, Vue, Express and Node's core modules are full of these patterns
  • Fewer reinventions: You stop solving the same structural problem from scratch on every project
  • Interviews: They still come up a lot in technical interviews

Understanding Pattern Categories

The classic grouping splits patterns into three buckets. Creational ones deal with how objects get made, structural ones with how they fit together, and behavioral ones with how they talk to each other. Knowing which bucket your problem falls into narrows down the options quickly.

Creational "How objects are created" • Singleton • Factory • Abstract Factory • Builder • Prototype Structural "How objects are composed" • Module • Facade • Decorator • Adapter • Proxy Behavioral "How objects communicate" • Observer • Strategy • Command • Iterator • Mediator

The three main categories of design patterns and their primary concerns

1. The Singleton Pattern

Creational Pattern

Some things in an app should exist exactly once: the config loader, the logger, the database connection pool. Opening a new pool on every request is a quick way to run out of connections. A Singleton guarantees there's only one instance and gives everyone the same way to get at it.

Request 1 Request 2 Request 3 Singleton getInstance() Single Instance Same Object Reference

Multiple requests always receive the same singleton instance

singleton.js
class DatabaseConnection {
  static instance = null;
  
  constructor() {
    if (DatabaseConnection.instance) {
      return DatabaseConnection.instance;
    }
    
    this.connection = this.createConnection();
    DatabaseConnection.instance = this;
  }
  
  createConnection() {
    // Simulate database connection
    console.log('Creating new database connection...');
    return {
      host: 'localhost',
      port: 5432,
      connected: true
    };
  }
  
  query(sql) {
    console.log(`Executing: ${sql}`);
    return { rows: [], affected: 0 };
  }
  
  static getInstance() {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection();
    }
    return DatabaseConnection.instance;
  }
}

// Usage - both variables reference the SAME instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();

console.log(db1 === db2); // true - same instance!
When to Use Singleton

Use it sparingly. It's fine for truly global resources (logging, caching, configuration), but every Singleton is a hidden dependency, and tests start leaking state into each other because the instance survives between them. In Jest you'll end up calling jest.resetModules() or adding a reset method just to get clean tests. Passing the dependency in (plain dependency injection) is usually easier to test.

One more gotcha: in Node, an exported instance (module.exports = new Logger()) is only a singleton per resolved file path. If two copies of the same package end up in node_modules at different versions, you get two "singletons". npm ls <package> will show the duplicates.

2. The Factory Pattern

Creational Pattern

When the type of object you need depends on runtime input (a config value, a user preference, a field in an API response), new calls with if checks tend to spread across the codebase. A Factory puts that decision in one place: callers ask for "a button" or "an email notification" and don't need to know which class comes back.

// Simple Factory for creating UI components
class Button {
  constructor(text) {
    this.text = text;
    this.type = 'button';
  }
  render() {
    return `<button>${this.text}</button>`;
  }
}

class Input {
  constructor(placeholder) {
    this.placeholder = placeholder;
    this.type = 'input';
  }
  render() {
    return `<input placeholder="${this.placeholder}" />`;
  }
}

class Select {
  constructor(options) {
    this.options = options;
    this.type = 'select';
  }
  render() {
    const opts = this.options.map(o => `<option>${o}</option>`).join('');
    return `<select>${opts}</select>`;
  }
}

// The Factory
class UIFactory {
  static create(type, config) {
    switch (type) {
      case 'button':
        return new Button(config.text);
      case 'input':
        return new Input(config.placeholder);
      case 'select':
        return new Select(config.options);
      default:
        throw new Error(`Unknown component: ${type}`);
    }
  }
}

// Usage
const submitBtn = UIFactory.create('button', { text: 'Submit' });
const emailInput = UIFactory.create('input', { placeholder: 'Enter email' });
// Factory Method Pattern - Let subclasses decide
class NotificationCreator {
  // Factory Method - to be overridden
  createNotification() {
    throw new Error('Implement createNotification()');
  }
  
  send(message) {
    const notification = this.createNotification();
    notification.format(message);
    notification.deliver();
  }
}

class EmailNotificationCreator extends NotificationCreator {
  createNotification() {
    return new EmailNotification();
  }
}

class SMSNotificationCreator extends NotificationCreator {
  createNotification() {
    return new SMSNotification();
  }
}

class PushNotificationCreator extends NotificationCreator {
  createNotification() {
    return new PushNotification();
  }
}

// Usage - the creator decides the type
function notifyUser(userPreference, message) {
  let creator;
  switch (userPreference) {
    case 'email': creator = new EmailNotificationCreator(); break;
    case 'sms': creator = new SMSNotificationCreator(); break;
    case 'push': creator = new PushNotificationCreator(); break;
  }
  creator.send(message);
}

Real-World Examples of Factory Pattern:

  • React.createElement(): creates React elements based on type
  • document.createElement(): the browser's DOM element factory
  • jQuery $(): creates jQuery objects from selectors
  • Express middleware: functions like express.json() return handler functions
// Real-world: API Response Factory
class ApiResponseFactory {
  static success(data, message = 'Success') {
    return {
      success: true,
      message,
      data,
      timestamp: new Date().toISOString()
    };
  }
  
  static error(error, code = 500) {
    return {
      success: false,
      error: error.message || error,
      code,
      timestamp: new Date().toISOString()
    };
  }
  
  static paginated(data, page, total, perPage) {
    return {
      success: true,
      data,
      pagination: {
        currentPage: page,
        totalPages: Math.ceil(total / perPage),
        totalItems: total,
        perPage
      }
    };
  }
}

3. The Module Pattern

Structural Pattern

Before ES modules, every script you loaded shared one global scope, and two libraries defining utils would quietly overwrite each other. The Module pattern fixed that with a closure: keep state and helpers private inside a function, return only the public API. Today import/export does most of this for you, but you'll still see the IIFE version in older code and in bundler output.

Module Private _privateVar _helper() _cache Public API init() getData() update() Accessible Hidden ✕

The Module pattern encapsulates private members and exposes only the public API

module-pattern.js
// Classic Module Pattern (IIFE)
const ShoppingCart = (function() {
  // Private variables
  let items = [];
  let total = 0;
  
  // Private methods
  function calculateTotal() {
    total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    return total;
  }
  
  function findItem(id) {
    return items.find(item => item.id === id);
  }
  
  // Public API
  return {
    addItem(product, quantity = 1) {
      const existing = findItem(product.id);
      if (existing) {
        existing.quantity += quantity;
      } else {
        items.push({ ...product, quantity });
      }
      return calculateTotal();
    },
    
    removeItem(id) {
      items = items.filter(item => item.id !== id);
      return calculateTotal();
    },
    
    getTotal() {
      return total;
    },
    
    getItems() {
      // Return a copy to prevent external mutation
      return [...items];
    },
    
    clear() {
      items = [];
      total = 0;
    }
  };
})();

// Usage
ShoppingCart.addItem({ id: 1, name: 'Laptop', price: 999 });
ShoppingCart.addItem({ id: 2, name: 'Mouse', price: 25 }, 2);

console.log(ShoppingCart.getTotal());  // 1049
console.log(ShoppingCart.getItems()); // [...]

// Private members are inaccessible
console.log(ShoppingCart.items);     // undefined
console.log(ShoppingCart.calculateTotal); // undefined

Encapsulation

Hide internal implementation details and protect data from external modification.

Namespace

Avoid global scope pollution by containing all functionality within a single object.

Organization

Group related functionality together, making code easier to maintain and understand.

Reusability

Create self-contained units you can lift into another project without dragging globals along.

4. The Observer Pattern

Behavioral Pattern

When a user logs in, the navbar needs to update, analytics wants an event, and the session store needs to save something. If login() calls all three directly, it has to know about all three, and every new feature means editing it. With an Observer, the subject (here, the auth service) just announces "login happened" and anything that cares subscribes. It's the same idea behind DOM events, Node's EventEmitter and RxJS.

Subject (Observable) subscribe() notify() Observer 1 UI Component update() Observer 2 Logger update() Observer 3 Analytics update()

The Subject notifies all subscribed Observers when state changes

observer.js
// EventEmitter - A flexible Observer implementation
class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  // Subscribe to an event
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
    
    // Return unsubscribe function
    return () => this.off(event, callback);
  }
  
  // Subscribe once
  once(event, callback) {
    const wrapper = (...args) => {
      callback(...args);
      this.off(event, wrapper);
    };
    return this.on(event, wrapper);
  }
  
  // Unsubscribe
  off(event, callback) {
    if (!this.events[event]) return;
    this.events[event] = this.events[event]
      .filter(cb => cb !== callback);
  }
  
  // Emit event to all subscribers
  emit(event, ...args) {
    if (!this.events[event]) return;
    this.events[event].forEach(callback => {
      callback(...args);
    });
  }
}

// Usage: User Authentication State
class AuthService extends EventEmitter {
  constructor() {
    super();
    this.user = null;
  }
  
  login(userData) {
    this.user = userData;
    this.emit('login', userData);
    this.emit('authStateChange', { isAuthenticated: true, user: userData });
  }
  
  logout() {
    const previousUser = this.user;
    this.user = null;
    this.emit('logout', previousUser);
    this.emit('authStateChange', { isAuthenticated: false, user: null });
  }
}

// Multiple observers listening to auth events
const auth = new AuthService();

// UI updates
auth.on('authStateChange', ({ isAuthenticated, user }) => {
  updateNavbar(isAuthenticated, user);
});

// Analytics tracking
auth.on('login', (user) => {
  analytics.track('user_login', { userId: user.id });
});

// Session management
auth.on('logout', () => {
  localStorage.removeItem('session');
});
Observer Pattern in Modern JavaScript

The Observer pattern is everywhere in JavaScript:

  • DOM Events: element.addEventListener('click', handler)
  • React/Vue: State changes trigger component re-renders
  • RxJS: Observables and subscriptions
  • Node.js: EventEmitter in core modules
  • Redux: Store subscribers are notified on state changes

The classic bug here is forgetting to unsubscribe. A React component that subscribes in useEffect without returning the cleanup function adds a new listener every time it mounts. In Node you'll eventually see MaxListenersExceededWarning: Possible EventEmitter memory leak detected once an emitter passes 10 listeners for one event. Raising the limit with setMaxListeners() hides the warning, but the leak is still there. That's why the on() above returns an unsubscribe function.

5. The Strategy Pattern

Behavioral Pattern

That checkout function with one if branch per payment provider is the textbook case for Strategy. Each variation (credit card, PayPal, crypto) becomes its own object with the same methods, and the code that runs the checkout just calls pay() on whichever one it's given. Adding a provider means adding a class, not editing a growing conditional.

strategy.js
// Payment Strategy Pattern

// Strategy Interface (implicitly defined in JS)
class PaymentStrategy {
  pay(amount) {
    throw new Error('pay() must be implemented');
  }
  validate() {
    throw new Error('validate() must be implemented');
  }
}

// Concrete Strategies
class CreditCardPayment extends PaymentStrategy {
  constructor(cardNumber, cvv, expiry) {
    super();
    this.cardNumber = cardNumber;
    this.cvv = cvv;
    this.expiry = expiry;
  }
  
  validate() {
    return this.cardNumber.length === 16 && this.cvv.length === 3;
  }
  
  pay(amount) {
    console.log(`Paid $${amount} using Credit Card ending in ${this.cardNumber.slice(-4)}`);
    return { success: true, method: 'credit_card' };
  }
}

class PayPalPayment extends PaymentStrategy {
  constructor(email) {
    super();
    this.email = email;
  }
  
  validate() {
    return this.email.includes('@');
  }
  
  pay(amount) {
    console.log(`Paid $${amount} using PayPal account ${this.email}`);
    return { success: true, method: 'paypal' };
  }
}

class CryptoPayment extends PaymentStrategy {
  constructor(walletAddress, currency = 'BTC') {
    super();
    this.walletAddress = walletAddress;
    this.currency = currency;
  }
  
  validate() {
    return this.walletAddress.length >= 26;
  }
  
  pay(amount) {
    console.log(`Paid $${amount} in ${this.currency} to wallet ${this.walletAddress.slice(0, 8)}...`);
    return { success: true, method: 'crypto', currency: this.currency };
  }
}

// Context - uses the strategy
class PaymentProcessor {
  constructor() {
    this.strategy = null;
  }
  
  setStrategy(strategy) {
    this.strategy = strategy;
  }
  
  checkout(amount) {
    if (!this.strategy) {
      throw new Error('Please select a payment method');
    }
    
    if (!this.strategy.validate()) {
      throw new Error('Invalid payment details');
    }
    
    return this.strategy.pay(amount);
  }
}

// Usage - strategy can be swapped at runtime
const processor = new PaymentProcessor();

// User selects credit card
processor.setStrategy(new CreditCardPayment('4111111111111111', '123', '12/25'));
processor.checkout(99.99);

// Later, user switches to PayPal
processor.setStrategy(new PayPalPayment('user@email.com'));
processor.checkout(49.99);

6. The Decorator Pattern

Structural Pattern

Say you want logging, caching and retries on a fetch call. You could write a CachedRetryingLoggedFetcher subclass, or you could wrap the function in small layers, each adding one behavior. That's a Decorator. In JavaScript it's usually just a higher-order function, and it's the same idea behind Express middleware and React higher-order components.

decorator.js
// Function Decorator Pattern

// Base function
function fetchData(url) {
  return fetch(url).then(res => res.json());
}

// Decorator: Add logging
function withLogging(fn) {
  return async function(...args) {
    console.log(`Calling ${fn.name} with:`, args);
    const start = Date.now();
    
    try {
      const result = await fn(...args);
      console.log(`${fn.name} completed in ${Date.now() - start}ms`);
      return result;
    } catch (error) {
      console.error(`${fn.name} failed:`, error);
      throw error;
    }
  };
}

// Decorator: Add caching
function withCache(fn, ttl = 60000) {
  const cache = new Map();
  
  return async function(...args) {
    const key = JSON.stringify(args);
    const cached = cache.get(key);
    
    if (cached && Date.now() - cached.timestamp < ttl) {
      console.log('Cache hit!');
      return cached.data;
    }
    
    const result = await fn(...args);
    cache.set(key, { data: result, timestamp: Date.now() });
    return result;
  };
}

// Decorator: Add retry logic
function withRetry(fn, maxRetries = 3) {
  return async function(...args) {
    let lastError;
    
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error;
        console.log(`Attempt ${attempt} failed, retrying...`);
        await new Promise(r => setTimeout(r, 1000 * attempt));
      }
    }
    
    throw lastError;
  };
}

// Compose decorators - order matters!
const enhancedFetch = withLogging(
  withCache(
    withRetry(fetchData)
  )
);

// Usage - now has logging, caching, and retry!
enhancedFetch('/api/users').then(data => console.log(data));

7. The Proxy Pattern

Structural Pattern

Sometimes you want to intercept reads and writes on an object: reject bad values, log access, or delay an expensive setup until someone actually uses it. A Proxy sits in front of the real object and does exactly that. JavaScript has this built in through the Proxy object, which is also how Vue 3's reactivity system tracks changes. One thing to know: if a set trap returns false, strict-mode code (including ES modules and classes) throws a TypeError, so return true or throw your own error, as below.

proxy.js
// Validation Proxy
function createValidatedObject(target, schema) {
  return new Proxy(target, {
    set(obj, prop, value) {
      const validator = schema[prop];
      
      if (validator) {
        const { valid, message } = validator(value);
        if (!valid) {
          throw new Error(`Invalid ${prop}: ${message}`);
        }
      }
      
      obj[prop] = value;
      return true;
    }
  });
}

// Define validation schema
const userSchema = {
  age(value) {
    if (typeof value !== 'number') {
      return { valid: false, message: 'must be a number' };
    }
    if (value < 0 || value > 150) {
      return { valid: false, message: 'must be between 0 and 150' };
    }
    return { valid: true };
  },
  email(value) {
    if (!value.includes('@')) {
      return { valid: false, message: 'must be valid email' };
    }
    return { valid: true };
  }
};

// Usage
const user = createValidatedObject({}, userSchema);
user.name = 'John';     // OK - no validator
user.age = 25;         // OK - valid age
user.email = 'john@example.com'; // OK - valid email
// user.age = -5;       // Error: Invalid age: must be between 0 and 150
// user.email = 'invalid'; // Error: Invalid email: must be valid email

// Lazy Loading Proxy (Virtual Proxy)
function createLazyLoader(expensiveInit) {
  let instance = null;
  
  return new Proxy({}, {
    get(target, prop) {
      if (!instance) {
        console.log('Initializing expensive resource...');
        instance = expensiveInit();
      }
      return instance[prop];
    }
  });
}

const heavyResource = createLazyLoader(() => {
  // Expensive initialization
  return { data: 'expensive data', process() { return 'processed'; } };
});

// Resource not initialized yet!
console.log('Resource created but not initialized');

// First access triggers initialization
console.log(heavyResource.data); // "Initializing..." then "expensive data"

Pattern Comparison: When to Use What

Pattern Category Use Case Example
Singleton Creational Single shared instance Configuration, Logging
Factory Creational Object creation logic UI Components, API Responses
Module Structural Encapsulation & namespacing Libraries, Services
Observer Behavioral Event-driven updates State Management, Events
Strategy Behavioral Interchangeable algorithms Payment, Sorting, Validation
Decorator Structural Add behavior dynamically Middleware, HOCs
Proxy Structural Control access to objects Validation, Lazy Loading

Best Practices and Guidelines

When to Use Design Patterns

  • Don't force it: Use patterns when they solve a real problem, not just because they exist
  • Start simple: Begin with the simplest solution; refactor to patterns when complexity demands it
  • Understand the problem: Make sure you understand why a pattern exists before applying it
  • Consider maintenance: Patterns should make code easier to maintain, not harder
  • Document your choices: When using patterns, document why you chose them

Common Anti-Patterns to Avoid

  • Singleton Abuse: Using Singleton when dependency injection would be better
  • Over-engineering: Applying patterns to simple problems that don't need them
  • Pattern Mania: Using multiple patterns when one would suffice
  • Ignoring Built-ins: Creating custom patterns when JavaScript/frameworks provide solutions
  • Tight Coupling: Implementing patterns in ways that increase coupling instead of reducing it

When I'd Reach for a Pattern (and When I Wouldn't)

My rule is to wait for the second or third time a problem shows up. A single if with two payment types doesn't need a Strategy; when the fourth provider arrives and the function is 200 lines long, it does. Refactoring toward a pattern at that point is cheap, and you'll know which parts actually vary.

Trade-offs to Weigh

  • Singleton: fine for a logger or connection pool. I'd avoid it for anything holding per-user or per-request state, especially on a server where one process handles many users at once.
  • Module (IIFE): in new code, just use ES modules. The IIFE version is worth recognizing, not writing.
  • Observer: great for decoupling, but event chains are hard to trace. If you can't tell what happens after emit('login') without searching the whole repo, a direct function call might be clearer.
  • Strategy and Factory: in JavaScript, a plain object of functions ({ card: payByCard, paypal: payByPaypal }) often does the job without any classes.
  • Decorator: keep the stack shallow. Five wrappers deep, stack traces get noisy and the order of caching vs. retry starts to matter in ways nobody remembers.
  • Proxy: convenient for validation and dev tooling, but every property access goes through a trap, so I'd keep it out of hot loops.

Once these seven feel familiar, Command, State and Mediator are worth a look next. They show up less often in everyday JavaScript, but you'll recognize them in undo stacks, form wizards and chat UIs.

JavaScript Design Patterns Best Practices Software Architecture Clean Code
Mayur Dabhi

Mayur Dabhi

Full Stack Developer passionate about clean code, design patterns, and building scalable applications. I write about web development best practices and modern JavaScript techniques.