Singleton Pattern
One instance, shared globally — why it feels convenient, and why it so often bites you.
If you've ever written a class with a getInstance() method so that "only one of
these ever exists," you've built a singleton. It's one of the most reached-for
patterns in software — and one of the easiest to misuse without realising it.
What a singleton actually is
Think of it like "the current President." There's only one at a time, and instead of creating a new one whenever you need them, you ask "who's the current President?" and get the same person back every time.
class Logger {
static #instance;
static getInstance() {
if (!Logger.#instance) {
Logger.#instance = new Logger();
}
return Logger.#instance;
}
info(msg) {
console.log(`[INFO] ${msg}`);
}
}
Logger.getInstance().info("app started");The mechanism
The other way to write it: the constructor doesn't have to return a fresh object. If a previous instance is stashed somewhere, return that instead.
let instance;
class Counter {
constructor() {
if (instance) return instance;
instance = this;
this.count = 0;
}
increment() {
return ++this.count;
}
}
const a = new Counter();
const b = new Counter();
a.increment();
console.log(b.count); // 1 — a and b are the same object
Object.is(a, b); // truenew Counter() twice gives you one object. That's the whole pattern: the
identity check in the constructor, plus a module-scoped variable holding the
result.
Freezing it is worth the extra line — otherwise any consumer can reassign
increment and every other consumer silently gets the new behaviour.
const singletonCounter = Object.freeze(new Counter());
export default singletonCounter;In modern JavaScript you rarely need the class
ES modules are already evaluated once and cached by specifier. A plain object exported from a module is a singleton, with no constructor games:
// counter.js
let count = 0;
export const increment = () => ++count;
export const getCount = () => count;Every module that imports this gets the same count. That's the version to
reach for — same guarantee, none of the ceremony, and it doesn't pretend to be a
class that can be instantiated when it can't.
A singleton is global mutable state with a nicer name. Two modules that both import it are now coupled through it, which shows up as tests that pass alone and fail in a suite — because the previous test left the count at 7.
Three ways singletons quietly cause problems
1. Shared state leaks across users
Your app runs on a server handling many people's requests at the same time — that's normal for websites: one running process, many visitors. If a singleton stores anything specific to a single user (their cart, their session), every other user hitting that same server reads and writes the same object.
Result: "why is user A suddenly seeing user B's data?" That's a security bug, not a style complaint, and it traces straight back to singleton misuse.
Rule of thumb: singletons are fine for things that are the same for everyone (shared config, a logger). They're dangerous for anything tied to one user or one request.
2. Tests start interfering with each other
A singleton keeps its state around for the life of the program — including during your test suite. If test 1 flips a setting, test 2 quietly inherits it, even though the two have nothing to do with each other. You get the most annoying kind of bug: tests that fail only sometimes, depending on run order.
Fixes:
- Give the singleton a
reset()method and call it before every test. - Better: don't hard-code
MyService.getInstance()deep inside your code — hand the dependency in instead (next section).
3. Hidden dependencies make code hard to trust
This is the sneaky one. Compare two versions of the same function:
// Version A: hidden dependency
function processOrder(order) {
Logger.getInstance().info("processing " + order.id);
}
// Version B: explicit dependency
function processOrder(order, logger) {
logger.info("processing " + order.id);
}In version A you can't tell what processOrder needs from its signature — it
secretly reaches out for a global logger. Someone reusing it in a new context
(say, a background job with no logger configured) discovers that requirement the
hard way, usually via a crash. Tracing the bug is no better: when the state is
wrong, the culprit is "anything that imported this module", which isn't a search
you can finish.
In version B the dependency is right there in the parameters. Anyone can see what the function needs, and a test can pass in a fake logger.
That single change — passing things in instead of reaching out for them — is dependency injection, and it's the fix for most singleton pain.
Better tools for the job
Backend service? Use a DI container (tsyringe, InversifyJS). You register the service once in one place and the container hands it out everywhere, but tests can swap in a fake before resolving it.
React app? Use Context. Every component inside a specific part of your UI gets access to it without it being truly global — and you can mount a different value in tests or Storybook without touching production code.
Shared state that changes over time (like "is the user logged in")? Use a
lightweight store like Zustand. It's just a function returning a hook — no
class, no getInstance(), trivially mockable.
So when is a singleton the right call?
It's fine when all of these hold:
- The data is the same for every user — not per-request, not per-user.
- You genuinely never need more than one instance, and nothing breaks if there were two.
- You won't need to swap it out in tests.
If any of them don't hold, reach for dependency injection, Context, or a store. The "one instance" guarantee is easy to get from any of them, without the hidden coupling and cross-test bleeding.
The one-line takeaway
A singleton isn't wrong — it's just easy to misuse. If you find yourself
hard-coding getInstance() deep inside a function, stop and ask whether you
could pass that dependency in instead. That one habit avoids most of the pain
this pattern is known for.