When to Use the Singleton Pattern in JS Apps

Learn when to use the singleton pattern in JavaScript applications. Covers practical use cases like configuration management, connection pools, caching, event buses, dependency injection alternatives, and common anti-patterns to avoid.

JavaScriptadvanced
15 min read

Not every shared resource needs a singleton. Understanding when the pattern adds value and when it introduces unnecessary coupling is essential for writing maintainable JavaScript. This guide covers practical use cases, alternatives, and pitfalls.

For the core implementation techniques, see JavaScript Singleton Pattern: Complete Guide.

Configuration Manager

javascriptjavascript
// config-manager.js
const defaults = {
  api: { baseUrl: "/api", timeout: 5000, retries: 3 },
  ui: { theme: "light", locale: "en", pageSize: 25 },
  features: { darkMode: true, betaFeatures: false },
};
 
let config = null;
 
function deepMerge(target, source) {
  const result = { ...target };
  for (const key of Object.keys(source)) {
    if (
      source[key] &&
      typeof source[key] === "object" &&
      !Array.isArray(source[key])
    ) {
      result[key] = deepMerge(result[key] || {}, source[key]);
    } else {
      result[key] = source[key];
    }
  }
  return result;
}
 
export function initConfig(overrides = {}) {
  if (config) {
    console.warn("Config already initialized, skipping re-init");
    return config;
  }
 
  const envOverrides = {
    api: {
      baseUrl: process.env.API_URL || defaults.api.baseUrl,
    },
  };
 
  config = Object.freeze(deepMerge(deepMerge(defaults, envOverrides), overrides));
  return config;
}
 
export function getConfig() {
  if (!config) throw new Error("Config not initialized. Call initConfig() first.");
  return config;
}
 
export function resetConfig() {
  config = null;
}

This works as a singleton because configuration must be consistent across the entire application. Multiple config instances would mean different parts of the app might disagree on API endpoints or feature flags.

Connection Pool

javascriptjavascript
class ConnectionPool {
  static #instance = null;
  #pool = [];
  #available = [];
  #waitQueue = [];
  #config = null;
 
  constructor(config) {
    if (ConnectionPool.#instance) return ConnectionPool.#instance;
 
    this.#config = {
      maxConnections: config.max || 10,
      minConnections: config.min || 2,
      idleTimeout: config.idleTimeout || 30000,
    };
 
    this.#warmPool();
    ConnectionPool.#instance = this;
  }
 
  static getInstance(config) {
    if (!ConnectionPool.#instance) {
      ConnectionPool.#instance = new ConnectionPool(config);
    }
    return ConnectionPool.#instance;
  }
 
  #warmPool() {
    for (let i = 0; i < this.#config.minConnections; i++) {
      const conn = this.#createConnection();
      this.#pool.push(conn);
      this.#available.push(conn);
    }
  }
 
  #createConnection() {
    return {
      id: `conn_${this.#pool.length + 1}`,
      createdAt: Date.now(),
      lastUsed: Date.now(),
      inUse: false,
    };
  }
 
  async acquire() {
    const conn = this.#available.pop();
    if (conn) {
      conn.inUse = true;
      conn.lastUsed = Date.now();
      return conn;
    }
 
    if (this.#pool.length < this.#config.maxConnections) {
      const newConn = this.#createConnection();
      newConn.inUse = true;
      this.#pool.push(newConn);
      return newConn;
    }
 
    return new Promise((resolve) => {
      this.#waitQueue.push(resolve);
    });
  }
 
  release(connection) {
    connection.inUse = false;
    connection.lastUsed = Date.now();
 
    if (this.#waitQueue.length > 0) {
      const waiting = this.#waitQueue.shift();
      connection.inUse = true;
      waiting(connection);
    } else {
      this.#available.push(connection);
    }
  }
 
  getStats() {
    return {
      total: this.#pool.length,
      available: this.#available.length,
      inUse: this.#pool.filter((c) => c.inUse).length,
      waiting: this.#waitQueue.length,
    };
  }
 
  static resetInstance() {
    ConnectionPool.#instance = null;
  }
}
 
// Usage
const pool = ConnectionPool.getInstance({ max: 20, min: 5 });
const conn = await pool.acquire();
// ... use connection
pool.release(conn);

In-Memory Cache with Eviction

javascriptjavascript
class AppCache {
  static #instance = null;
  #store = new Map();
  #maxSize;
  #defaultTTL;
  #hits = 0;
  #misses = 0;
 
  constructor({ maxSize = 500, defaultTTL = 300000 } = {}) {
    if (AppCache.#instance) return AppCache.#instance;
    this.#maxSize = maxSize;
    this.#defaultTTL = defaultTTL;
    AppCache.#instance = this;
  }
 
  static getInstance(options) {
    if (!AppCache.#instance) {
      AppCache.#instance = new AppCache(options);
    }
    return AppCache.#instance;
  }
 
  set(key, value, ttl = this.#defaultTTL) {
    if (this.#store.size >= this.#maxSize && !this.#store.has(key)) {
      this.#evictOldest();
    }
 
    this.#store.set(key, {
      value,
      expiresAt: Date.now() + ttl,
      createdAt: Date.now(),
    });
  }
 
  get(key) {
    const entry = this.#store.get(key);
    if (!entry) {
      this.#misses++;
      return undefined;
    }
 
    if (Date.now() > entry.expiresAt) {
      this.#store.delete(key);
      this.#misses++;
      return undefined;
    }
 
    this.#hits++;
    return entry.value;
  }
 
  getOrSet(key, factory, ttl) {
    const cached = this.get(key);
    if (cached !== undefined) return cached;
 
    const value = factory();
    this.set(key, value, ttl);
    return value;
  }
 
  #evictOldest() {
    let oldestKey = null;
    let oldestTime = Infinity;
 
    for (const [key, entry] of this.#store) {
      if (entry.createdAt < oldestTime) {
        oldestTime = entry.createdAt;
        oldestKey = key;
      }
    }
 
    if (oldestKey) this.#store.delete(oldestKey);
  }
 
  getStats() {
    return {
      size: this.#store.size,
      maxSize: this.#maxSize,
      hitRate: this.#hits + this.#misses > 0
        ? ((this.#hits / (this.#hits + this.#misses)) * 100).toFixed(1) + "%"
        : "0%",
    };
  }
 
  clear() {
    this.#store.clear();
    this.#hits = 0;
    this.#misses = 0;
  }
 
  static resetInstance() {
    AppCache.#instance = null;
  }
}
 
// Same cache instance throughout the app
const cache = AppCache.getInstance({ maxSize: 1000 });
cache.set("user:123", { name: "Alice" });

Event Bus

javascriptjavascript
// event-bus.js - Perfect singleton candidate
const listeners = new Map();
const onceListeners = new Map();
 
export function on(event, handler) {
  if (!listeners.has(event)) listeners.set(event, []);
  listeners.get(event).push(handler);
  return () => off(event, handler);
}
 
export function once(event, handler) {
  if (!onceListeners.has(event)) onceListeners.set(event, []);
  onceListeners.get(event).push(handler);
}
 
export function off(event, handler) {
  const handlers = listeners.get(event);
  if (handlers) {
    const idx = handlers.indexOf(handler);
    if (idx > -1) handlers.splice(idx, 1);
  }
}
 
export function 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);
}
 
export function clear() {
  listeners.clear();
  onceListeners.clear();
}

Dependency Injection Alternative

javascriptjavascript
// Instead of singletons, pass dependencies explicitly
 
class UserService {
  #db;
  #cache;
  #logger;
 
  constructor({ db, cache, logger }) {
    this.#db = db;
    this.#cache = cache;
    this.#logger = logger;
  }
 
  async getUser(id) {
    const cached = this.#cache.get(`user:${id}`);
    if (cached) {
      this.#logger.debug("Cache hit", { userId: id });
      return cached;
    }
 
    this.#logger.info("Fetching user from DB", { userId: id });
    const user = await this.#db.query("SELECT * FROM users WHERE id = $1", [id]);
    this.#cache.set(`user:${id}`, user);
    return user;
  }
}
 
// Composition root - create instances once
function createApp() {
  const logger = new ConsoleLogger("info");
  const db = new DatabaseClient("postgres://localhost/app");
  const cache = new MemoryCache({ maxSize: 500 });
 
  const userService = new UserService({ db, cache, logger });
  const orderService = new OrderService({ db, cache, logger });
 
  return { userService, orderService, logger, db, cache };
}
 
// Test-friendly: inject mocks
function createTestApp() {
  const logger = { debug() {}, info() {}, error() {} };
  const db = { query: () => Promise.resolve({ rows: [] }) };
  const cache = new Map();
 
  return new UserService({ db, cache, logger });
}
Decision FactorUse SingletonUse DI Instead
Resource is truly globalConfiguration, feature flagsPer-request state
Expensive to create multipleDB connection poolsLightweight services
No need for test doublesRead-only configBusiness logic
Simple applicationSmall projects, scriptsMedium to large apps
Multiple configurations neededNeverMultiple environments
Deep call chains need accessService locator fallbackConstructor injection

Anti-Patterns to Avoid

javascriptjavascript
// ANTI-PATTERN 1: God singleton (too many responsibilities)
class AppState {
  // BAD: manages users, cart, UI state, API calls, etc.
  #users = [];
  #cart = [];
  #theme = "light";
  #notifications = [];
 
  // This should be split into separate concerns
}
 
// ANTI-PATTERN 2: Mutable shared state without protection
const globalState = {
  currentUser: null,
  settings: {},
};
// Anyone can modify without validation
globalState.settings = "oops, wrong type";
 
// BETTER: Encapsulate mutations
const state = (() => {
  let currentUser = null;
 
  return {
    getCurrentUser() {
      return currentUser;
    },
    setCurrentUser(user) {
      if (user && typeof user.id !== "string") {
        throw new TypeError("User must have a string id");
      }
      currentUser = user ? Object.freeze({ ...user }) : null;
    },
  };
})();
Rune AI

Rune AI

Key Insights

  • Configuration and connection pools are ideal singleton candidates: These resources must be consistent app-wide and expensive to duplicate
  • ES module scope provides the simplest singleton behavior: Module-level variables are shared across all imports with zero boilerplate
  • Dependency injection solves testability problems singletons create: Pass dependencies through constructors rather than calling getInstance() internally
  • Avoid god singletons that manage too many concerns: Split large singletons into focused, single-responsibility modules
  • Use a composition root to wire up singletons at app startup: Create all shared instances in one place and inject them into services that need them
RunePowered by Rune AI

Frequently Asked Questions

Should I use a singleton for API clients?

For most web applications, a single API client instance makes sense because it can share configuration (base URL, headers, interceptors) and manage rate limiting or request queuing. However, if you need different configurations for different services (auth API vs data API), create separate named instances rather than one singleton.

How do I avoid the hidden dependency problem with singletons?

Wrap singleton access in a function parameter default: `function processOrder(order, db = Database.getInstance())`. This keeps production code simple while allowing tests to pass mock objects. Even better, use a composition root pattern where all singletons are created once and injected into dependent services.

Is the module pattern already a singleton?

Yes. ES modules are evaluated once and cached by the runtime. Any state declared at module level is shared across all imports. This means a simple module with exported functions and module-scoped variables is already the simplest form of singleton in JavaScript, requiring no getInstance() pattern.

Can I have multiple singletons of the same "type"?

That contradicts the pattern definition, but you can use a singleton registry or a multiton pattern where instances are keyed by name. For example, you might have one logger per component: `LoggerFactory.get("auth")` returns the auth logger singleton while `LoggerFactory.get("api")` returns a different one.

Conclusion

Use singletons for truly global, expensive, or shared-state resources like configuration, connection pools, and caches. For business logic and testable services, prefer dependency injection. The module pattern provides the simplest singleton behavior in modern JavaScript. For implementation details, see JavaScript Singleton Pattern: Complete Guide. For module-level encapsulation, review JavaScript Module Pattern: Advanced Tutorial.