The Singleton Pattern
The singleton pattern guarantees that a class or resource has exactly one instance for the lifetime of your process, and gives every part of your application a single point of access to it. In Node.js this matters most for things you genuinely want shared — a database connection pool, a parsed configuration object, a logger, or a cache. The good news is that Node’s module system already implements singletons for you through module caching, so you rarely need the ceremonious class-based versions you see in other languages.
How module caching makes singletons
When you import (or require) a module, Node loads, evaluates, and executes it once, then stores the result in an internal cache keyed by the resolved file path. Every subsequent import of that same path returns the exact same exported value — the module body does not run again. That means any object you export from a module is, by default, a process-wide singleton.
// config.js
const config = {
port: Number(process.env.PORT ?? 3000),
env: process.env.NODE_ENV ?? "development",
loadedAt: new Date().toISOString(),
};
export default config;
// a.js
import config from "./config.js";
console.log("a sees:", config.loadedAt);
// b.js
import config from "./config.js";
config.port = 8080; // mutating the shared instance
console.log("b sees:", config.loadedAt, config.port);
// main.js
import "./a.js";
import "./b.js";
import config from "./config.js";
console.log("main sees:", config.loadedAt, config.port);
Output:
a sees: 2026-06-14T10:15:02.114Z
b sees: 2026-06-14T10:15:02.114Z 8080
main sees: 2026-06-14T10:15:02.114Z 8080
The identical loadedAt timestamp proves the module body ran only once, and the mutation in b.js is visible everywhere. No class, no getInstance() — just an exported object.
A lazily initialized singleton
A common real-world need is a singleton that performs expensive setup the first time it is used, then reuses the result. A database connection pool is the canonical example. Export an accessor that builds the resource on first call and caches it in module scope.
// db.js
import { Pool } from "pg";
let pool;
export function getPool() {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
});
console.log("Pool created");
}
return pool;
}
export async function query(text, params) {
const result = await getPool().query(text, params);
return result.rows;
}
// usage.js
import { query } from "./db.js";
const a = await query("SELECT now() AS ts");
const b = await query("SELECT count(*) FROM users");
console.log(a[0].ts, b[0].count);
Output:
Pool created
2026-06-14T10:15:03.402Z 1284
Because getPool closes over the module-scoped pool, the pool is created exactly once no matter how many modules call query.
Avoid creating connection pools or other shared resources at the top level of a module if they have side effects on import. Lazy initialization with an accessor keeps imports cheap and makes the resource easier to mock or skip in tests that never touch the database.
Class-based singletons
Sometimes you want a richer object with methods and private state. You can still lean on module caching by exporting a single constructed instance, which is cleaner than the classic getInstance static.
// logger.js
class Logger {
#lines = 0;
log(message) {
this.#lines += 1;
console.log(`[${this.#lines}] ${message}`);
}
}
export default new Logger(); // constructed once, cached by the module system
If you truly need the explicit getInstance form — for example to control construction arguments — keep the instance in a static field:
// counter.js
export class Counter {
static #instance;
#value = 0;
static get() {
Counter.#instance ??= new Counter();
return Counter.#instance;
}
increment() {
return ++this.#value;
}
}
When module caching does not give you a singleton
The “one instance” guarantee is per module cache, not per machine. Several situations break the assumption:
| Situation | Why the singleton splits |
|---|---|
Worker threads / child_process | Each thread or process has its own module cache and memory. |
| Cluster mode / multiple instances | Each forked process loads modules independently. |
| Duplicate package versions | node_modules/a/node_modules/lib and a top-level lib resolve to different paths. |
| Different specifier paths | Symlinks or mixed ./db.js vs /abs/db.js can resolve differently. |
| ESM vs CommonJS of the same package | The two module systems maintain separate caches. |
For genuinely cross-process shared state — a counter, a lock, a cache — you need an external store such as Redis, not a JavaScript singleton.
Testing and hidden global state
The biggest drawback of singletons is the hidden, mutable global state they introduce. Tests that share a singleton can leak state into one another, producing order-dependent failures. Mitigate this with an explicit reset hook or by re-importing a fresh module instance.
// db.js (add a test-only reset)
export async function closePool() {
if (pool) {
await pool.end();
pool = undefined; // next getPool() rebuilds it
}
}
import { afterEach } from "node:test";
import { closePool } from "./db.js";
afterEach(async () => {
await closePool();
});
A singleton is effectively a global variable wearing a nicer hat. Prefer passing dependencies explicitly where you can, and reserve singletons for resources that are genuinely process-wide. This keeps modules pure, testable, and decoupled — exactly the goals dependency injection addresses.
Best Practices
- Reach for module caching first — an exported object or instance is already a singleton, so avoid reinventing
getInstanceunless you need it. - Initialize expensive resources lazily through an accessor function rather than at import time, so importing a module has no side effects.
- Provide a
close/resetfunction for any stateful singleton so tests (and graceful shutdown) can tear it down deterministically. - Never assume a singleton is shared across worker threads, cluster workers, or processes; use Redis or another external store for cross-process state.
- Keep singletons immutable where possible; freeze config objects with
Object.freezeto prevent accidental mutation across modules. - Limit singletons to truly global concerns (config, logging, connection pools) and pass everything else as explicit dependencies.