Singleton Pattern in Node.js
Part of my Node.js Design Patterns series.
A service I inherited opened a new database pool for every incoming request. It had been written that way for a year. It worked because traffic was low, and then a marketing campaign doubled traffic and the database hit its connection limit at the exact moment it mattered. The fix was four lines. The lesson was that "one instance of this thing" is a decision you have to make explicitly, because the default in a codebase with many authors is "one per wherever I happened to be".
That decision is the Singleton pattern. In most languages it needs a private constructor and a static accessor. In Node it needs a module and a const, which is why people say the pattern is unnecessary here. It is not unnecessary. It is just short.
The pattern in modern Node
// One pool per process. Every importer of this module gets the same object,
// because ES modules are evaluated once and cached by URL.
function createPool() {
console.log("pool created");
return {
async query(sql) {
return { sql, rows: [] };
},
};
}
export const pool = createPool();import { pool } from "./db.mjs";
import { pool as again } from "./db.mjs";
console.log(pool === again); // true, and "pool created" printed once
console.log(await pool.query("select 1"));That is the whole pattern for the common case. The module cache is your registry, and the const is your instance.
Lazy creation and a reset for tests
Two things the eager version above lacks: it creates the pool the moment anything imports the module, even a CLI that only needed one helper from it; and tests cannot swap it. The classic fix covers both:
let instance = null;
export function getPool({ create = defaultCreate } = {}) {
if (!instance) instance = create();
return instance;
}
export function resetPoolForTests() {
instance = null;
}
function defaultCreate() {
return {
async query(sql) {
return { sql, rows: [] };
},
};
}import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { getPool, resetPoolForTests } from "./db-lazy.mjs";
beforeEach(() => resetPoolForTests());
test("returns the same pool twice", () => {
assert.equal(getPool(), getPool());
});
test("can be replaced with a fake", async () => {
const fake = { async query() { return { rows: [{ ok: 1 }] }; } };
const pool = getPool({ create: () => fake });
assert.deepEqual(await pool.query("x"), { rows: [{ ok: 1 }] });
});resetPoolForTests is an ugly name on purpose. It should look wrong in production code, because it is.
Where it goes wrong
Singleton means one per process, not one per system. Run the service on three Vercel functions or three Kubernetes pods and you have three pools, three caches, three "the" schedulers. That is usually fine for a pool and disastrous for a scheduler. If the thing must be unique across the system, the singleton needs a lease in the database or a lock in Redis; the module cache cannot help you.
Worker threads and cluster. Each worker is its own process or isolate with its own module cache. A singleton counter in the main thread is invisible to workers. The old "Node is single-threaded so singletons are safe" line was never the whole story, and it is less true every year.
Hidden coupling. When ten modules import pool directly, none of them can be tested without a database, and none of them declare that dependency. The getPool({ create }) shape above is one answer. Passing the pool in from the composition root (the file that wires the app together) is a cleaner one for anything larger than a small service.
Two copies of the module. A package installed twice in node_modules, or a file imported once as ./db.mjs and once through a symlink with a different resolved path, gives you two module instances and two singletons. The symptom is "the cache is empty but I just filled it". Check import.meta.url in both places when that happens.
When not to use it
Anything that holds per-request state must not be a singleton, however tempting the const looks. Request context belongs in the request, or in AsyncLocalStorage if it needs to travel through layers. The singleton is for the expensive, shared, stateless-per-call things: pools, clients, compiled templates, loaded configuration.
Next: the Observer Pattern, where Node's EventEmitter does most of the work and the async trap does the rest.

Nguyễn Hải Nam
Nguyễn Hải Nam
Project Management Lead. 16+ years from code to delivery. PMP®. Writing here about project management and engineering.