Singleton, and Why It Usually Hurts
“I logged in to my own account and I’m looking at another company’s unpaid invoices.” That was the whole support ticket. A customer’s name, a screenshot that should not have existed, and a very good question about who else could see her data.
The account was hers. The data was not. She had opened her billing page at the same instant a user from a different company opened theirs, and for the length of one database call the server got the two of them confused. Nobody was attacking anything. The code just had one shared object where it needed one per request.
Here is the shape of it, trimmed to the part that mattered:
class Db {
static #instance;
#tenantId = null;
static getInstance() {
return (Db.#instance ??= new Db());
}
setTenant(id) { this.#tenantId = id; }
query(sql) { return run(sql, { tenant: this.#tenantId }); }
}
One Db, reachable from anywhere by calling getInstance(). To avoid threading a tenant id through every function that might touch the database, someone added setTenant, so a request could stamp “this is acme” onto the shared object once and let every query downstream read it back. In development, where you fire one request at a time, it worked flawlessly. It shipped.
Then two requests overlapped:
async function billingPage(req, res) {
const db = Db.getInstance();
db.setTenant(req.user.tenantId); // request A stamps "acme"
const profile = await loadProfile(req); // <-- await yields the event loop
const rows = await db.query("select * from invoices");
res.json(rows); // acme's user, globex's invoices
}
The await is the whole crime. When request A hits it, the engine parks A and is free to go run request B, which calls setTenant("globex") on the exact same shared object. When A wakes up and runs its query, this.#tenantId is now "globex". A single shared field, two requests taking turns writing it.
The singleton was not the bug on its own. Mutable state behind a global door was. But the singleton is the thing that installed the door, and installing that door is the pattern’s defining move: one instance, reachable from everywhere. This article is about why that move, so tidy on a whiteboard, is the design pattern most likely to hurt you, and what to reach for instead.
What the singleton actually promises
Two guarantees, and only two: exactly one instance of some thing, and a global point of access to it. That is the textbook definition and it has not changed since 1994.
The instinct behind it is completely reasonable. Some things genuinely should be single. You want one connection pool, not forty. You want config loaded once at boot, one logger, one in-memory cache that every part of the app reads and writes. The need for “one shared thing that everyone reaches” is real and it is common.
What is worth arguing about is how you meet that need, because the classic answer imported from Java and C# is almost never the right one in JavaScript. The language already solved this, quietly, years ago.
JavaScript already handed you one
A module’s top level runs once, the first time it is imported. Every other file that imports it gets the same evaluated bindings, not a fresh copy. So this is a singleton:
// db.js
import { createPool } from "./pool.js";
export const pool = createPool(process.env.DATABASE_URL);
createPool runs a single time. Every file that writes import { pool } from "./db.js" receives that same pool. No static field, no getInstance, no guard against a second instance, because you could not make a second one if you tried. There is no constructor to call twice.
The module pattern chapter makes this point in passing. It is worth making again here, loudly, because it is the entire reason the class version below is usually pointless. If you want one shared thing in JavaScript, a module already is one.
The ritual you will still meet
Plenty of JavaScript codebases hand-roll the class version anyway, usually written by someone fluent in a class-first language:
class Logger {
static #instance;
#buffer = [];
static getInstance() {
return (Logger.#instance ??= new Logger());
}
log(line) { this.#buffer.push(line); }
}
Logger.getInstance().log("boot");
It works. It is also a from-scratch reimplementation of what export const logger = makeLogger() gives you with none of the moving parts. The static #instance, the nullish check, the private constructor people sometimes bolt on to block new Logger() from outside, all of it is machinery to guarantee single-instance-ness that the module loader already guarantees.
In a class-first language the ceremony earns its keep. A class is often the only unit of code, and the double-checked locking you see in a “proper” singleton is a genuine defense against two threads racing to construct the instance at once. Neither pressure exists here. Inside one realm nothing runs in parallel, so there is no construction race, and the module is a finer, cheaper unit than a class. Recognise the pattern so you can read old code, then reach for the module.
It is global mutable state wearing a costume
Now the part senior engineers flinch at. Set the two guarantees next to each other and read what “global point of access” really says: any code, anywhere, can reach the one instance and change it. A singleton holding mutable state is a global variable in a nicer outfit, and everything the industry learned to distrust about global variables comes right back. The design-pattern name on the box does nothing to stop it.
It bites in three specific places. They are the same “shared mutable state” problem underneath, wearing three different sets of clothes, and each one shows up in a different part of your week.
It wrecks your tests
Start with the smallest, most common version:
// featureFlags.js
const flags = new Map();
export const setFlag = (k, v) => flags.set(k, v);
export const isOn = (k) => flags.get(k) === true;
One flags map for the whole process. Two tests lean on it:
test("checkout shows the new banner when the flag is on", () => {
setFlag("newBanner", true);
expect(render().includes("banner")).toBe(true);
});
test("checkout hides the banner by default", () => {
expect(isOn("newBanner")).toBe(false); // green alone, red after the first test
});
Run the second test on its own and it passes. Run the file and the first test has left newBanner sitting at true in the shared map, so the second inherits that write and fails. Your result now depends on the order the tests ran in, which is the working definition of a flaky suite.
It has quietly gotten worse as runners got faster. A modern runner like Vitest defaults to a thread pool that keeps each worker alive and loads a given module once per worker, so module-level state survives from one test file into the next inside the same thread. Two files that both import featureFlags.js share that one map across the whole run, not just within a file. You can paper over it, resetting the map in a beforeEach or forcing re-evaluation with vi.resetModules(), but every one of those is you manually undoing global state that had no business being global. Turn on full per-file isolation and you buy back correctness with wall-clock time. The state was the problem. Isolation is just the rent you pay to keep it.
The version on the right never has this problem, because there is nothing to share. That is the fix, and it has a name we will get to. First, the other two symptoms.
You cannot reason about it
When state lives behind a global door, you lose the ability to answer “what can change this?” by reading the code in front of you. The honest answer is “anything in the program,” because anything can call getInstance() and write to the result. A value flips, and the line that flipped it is three modules and one stack trace away.
This is the “spooky action at a distance” that makes shared-singleton bugs so slow to pin down. The symptom and the cause do not live in the same neighborhood. Play with it. Both panels below read a counter. Flip between one shared instance and one per module, then increment on the left and watch the right.
In shared mode, module A’s click reaches into module B’s display, because they were never two things. In injected mode the two are genuinely separate and a click stays local. Same code, one wiring change, and the whole class of surprise disappears.
It leaks across requests and tenants
The opening bug lives here, and it is the worst of the three, because it is a security problem and not merely a correctness one. A server handles many requests concurrently. Not in parallel, JavaScript runs one thing at a time, but interleaved, because every await is a point where the engine can go run somebody else’s request while yours waits on I/O.
So a per-request value stored on a process-wide singleton is a value that different requests take turns overwriting. Trace the two from the top of the article:
The single-threaded model is exactly what makes this feel safe and exactly why it is not. You reason “there is no parallelism, so setTenant and then query cannot be interrupted,” and you are right about parallelism and wrong about interleaving. Between those two calls sits an await, and an await is a standing invitation for another request to run. Multi-tenancy is where this stops being a bug and becomes a breach.
The correct home for request-specific state is the request. Pass it down the call chain explicitly, or bind it to the request’s async context. Node’s AsyncLocalStorage is built for precisely this and already does the request-id plumbing behind your observability. What it must never be is a mutable field on one shared object.
The fix is boring: pass it in
All three symptoms share a root and a cure. The root is that the code reaches out to grab a global. The cure is to make the code receive what it needs as an argument, and let whoever calls it decide what to hand over. That is dependency injection, and the name is far grander than the idea.
Here is the before, reaching through the wall for the global pool:
import { pool } from "./db.js";
export async function getUser(id) {
const { rows } = await pool.query("select * from users where id = $1", [id]);
return rows[0];
}
getUser is welded to that one pool. To test it you have to stand up the real database, monkey-patch the module, or intercept the network, because there is no seam to slip a fake through.
Now the after. The pool comes in through the door:
export function makeUserRepo(pool) {
return {
async getUser(id) {
const { rows } = await pool.query("select * from users where id = $1", [id]);
return rows[0];
},
};
}
Same query, same logic, one difference: pool arrives as an argument instead of being grabbed from module scope. Production wires the real one once, at the top of the app:
// main.js, the single place that names the concrete pool
import { pool } from "./db.js";
const users = makeUserRepo(pool);
and a test wires a fake, in the test, with nothing to reset afterward:
const users = makeUserRepo({
query: async () => ({ rows: [{ id: 1, name: "ada" }] }),
});
// no database, no network, no shared state to pollute the next test
Notice what did not change. You still create the real pool exactly once. It is still a single shared instance. The difference is that “single instance” is now a wiring decision made in one file, not a rule that every other file has to route through a getInstance() to obey. You kept the one good thing about the singleton, the shared pool, and dropped the bad one, the global door. This is the same shape as the factory: a function that takes its dependencies and returns an object built over them.
When a singleton is genuinely fine
Time to be fair to it. The pattern is not evil, it is specific, and there is a clean line. The line is about mutability and scope, not about the word “singleton.”
A shared instance is fine when it holds no mutable state that differs by request, by user, or by test. Two shapes qualify cleanly.
Stateless utilities. A module of pure functions, a formatter, a math helper, a bundle of validators, is shared by everyone and shared safely, because there is nothing to corrupt. No state means no pollution and no leak. Calling that a “singleton” is almost too grand. It is just a module, and it is the least controversial thing in your codebase.
Process-wide immutable config. Config read once at boot and never written again is the textbook good singleton. It is shared, it is global, and it is completely safe precisely because it cannot change out from under anyone.
// config.js
export const config = Object.freeze({
region: process.env.REGION ?? "us-east-1",
maxUploadMb: 25,
});
Object.freeze turns “please do not mutate this” into a rule the runtime enforces, so the shared-ness can never turn into a shared-mutation bug.
And the mirror of the rule: a shared instance bites the moment it holds anything mutable that belongs to one request, one user, or one test. A cache is the interesting middle case. One shared cache is often exactly right (see caching layers), but a cache keyed carelessly is how one tenant’s row ends up served to another, which is the opening bug wearing a performance optimisation’s clothes.
So the question to ask is never “should this be a singleton?” It is “what state does this hold, who is allowed to write it, and does that state belong to the whole process or to one request?” Answer that and the wiring falls out on its own.
Summary
- A singleton promises exactly one instance and a global point of access to it. The second promise is the dangerous one: global access means any code, anywhere, can reach in and mutate the shared thing.
- In JavaScript you almost never write the class version. A module is evaluated once and its exports are shared, so
export const pool = createPool()already is a singleton. Theclass Config { static #instance }ritual rebuilds what the module loader gives you for free. - “One instance” means one per realm, not one per process. Worker threads get their own, and CommonJS can hand you duplicates through symlinks or path casing. For truly process-global state you need shared memory or an external store.
- A singleton holding mutable state is a global variable in costume, and it bites in three places: it makes tests order-dependent and flaky (a module singleton leaks across files in the same worker thread), it makes state impossible to reason about (anything can change it from anywhere), and it leaks request-specific data across concurrent requests, which is a security bug, not a style one.
- The concurrency leak survives “JavaScript is single-threaded” reasoning, because the danger is interleaving at every
await, not parallelism. Per-request state belongs on the request (AsyncLocalStorage) or passed down, never on a shared object. - The fix for all three is dependency injection, which in JavaScript is just passing the thing in as an argument instead of importing a global. Production wires the real instance once; a test wires a fake. No framework required until the graph is genuinely large. See dependency injection.
- When a singleton is fine: stateless utilities and immutable, boot-time config (
Object.freezeit, and remember freeze is shallow). When to avoid it: anything mutable, request-specific, or test-relevant. If you would mock it, inject it. - The verdict: prefer a plain module or an injected dependency. Reach for an explicit, hand-rolled singleton rarely, knowingly, and only for something that is genuinely one-per-process and safe to share.