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.
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
// 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
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
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
// 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
// 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 Factor | Use Singleton | Use DI Instead |
|---|---|---|
| Resource is truly global | Configuration, feature flags | Per-request state |
| Expensive to create multiple | DB connection pools | Lightweight services |
| No need for test doubles | Read-only config | Business logic |
| Simple application | Small projects, scripts | Medium to large apps |
| Multiple configurations needed | Never | Multiple environments |
| Deep call chains need access | Service locator fallback | Constructor injection |
Anti-Patterns to Avoid
// 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
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
Frequently Asked Questions
Should I use a singleton for API clients?
How do I avoid the hidden dependency problem with singletons?
Is the module pattern already a singleton?
Can I have multiple singletons of the same "type"?
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.
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.