Singleton: Guaranteeing One Global Instance, and What It Costs You
From hand-rolling a Singleton, to the traps it sets in multi-threaded and async environments, to why it gets called an anti-pattern — here's what this pattern actually buys you, and what it takes away.
What problem is the Singleton Pattern actually solving
Picture this: a company can only have one CEO. It doesn't matter who's asking, from which department, or when — the answer to "who's the CEO" always has to be the same person. You don't get a second CEO just because a different person asked.
That's exactly what the Singleton Pattern does. It guarantees a class gets instantiated at most once over the lifetime of an application, and gives you a global access point so anywhere in your code can reach that one instance.
Common use cases:
- Application configuration management
- Database connection pools
- Logging systems
- Caching and state management
Hand-rolling the most basic Singleton
Let's implement a bare-bones version in JavaScript to get a feel for the core logic:
class Logger {
static #instance
constructor() {
if (Logger.#instance) {
throw new Error('Use Logger.getInstance() to get the instance')
}
this.logs = []
Logger.#instance = this
}
static getInstance() {
if (!Logger.#instance) {
Logger.#instance = new Logger()
}
return Logger.#instance
}
log(message) {
this.logs.push(message)
console.log(`[LOG] ${message}`)
}
}
const logger1 = Logger.getInstance()
const logger2 = Logger.getInstance()
console.log(logger1 === logger2) // true — both calls return the same instanceA few things are doing the work here:
- A private static field,
#instance, holds the one instance - The constructor blocks anyone from calling
new Logger()directly getInstance()is the single entry point — it creates the instance on the first call, then hands back the same one every time after
Pitfall one: race conditions in multi-threaded environments
The code above is safe in JavaScript because JavaScript is single-threaded — only one piece of code runs at a time, so there's never two places racing to evaluate if (!Logger.#instance) simultaneously.
Port that same logic to a language with real multi-threading — Java, C++, Go — and the story changes. Say two threads call getInstance() at nearly the same moment. Here's a timing that can play out:
- Thread A checks whether
instanceis null — it is - Thread B checks too, before A has finished creating one — also null
- A creates an instance
- B creates an instance
Now you've got two "singleton" instances. This kind of bug — where the outcome depends on execution order — is a race condition, and it's exactly why textbook treatments of the Singleton Pattern always bring up locks or double-checked locking.
You don't need to worry about this on JavaScript's main thread, but if you ever work with Node.js worker threads, or a backend service in another language, and you hit a weird bug where a singleton somehow got created more than once — this is the first place to look.
Pitfall two: async initialization can bite you too
Even in single-threaded JavaScript, if building a Singleton involves an async step — say, hitting an API before the instance is ready — you can still end up with duplicates if you're not careful:
class Config {
static #instance
static #instancePromise
static async getInstance() {
if (Config.#instance) {
return Config.#instance
}
// an initialization is already in flight — return that same promise
if (!Config.#instancePromise) {
Config.#instancePromise = fetch('/api/config')
.then((res) => res.json())
.then((data) => {
Config.#instance = new Config(data)
return Config.#instance
})
}
return Config.#instancePromise
}
constructor(data) {
this.data = data
}
}The key move is caching not just the finished instance, but the in-flight initialization promise. Skip that, and two calls to getInstance() in quick succession will fire off a second fetch before the first one even resolves — defeating the whole point of "only one."
The trade-off: why Singleton gets called an anti-pattern
The Singleton Pattern solves "one global instance," but it comes with real costs — which is exactly why it shows up so often in anti-pattern discussions:
- Hidden dependencies that are hard to trace. Any code can just call
Logger.getInstance()and get the instance — no need to pass it in as a parameter. Convenient on the surface, but it also means the dependency never shows up in the function signature. Anyone reading the code has no easy way to tell that a function secretly depends on some global object. - Hard to test. Since the state is globally shared, one test mutating the Singleton can leak into the next test. Tests end up affecting each other, and running them in a different order can change the results.
- Violates dependency inversion. Good design usually favors dependency injection — passing dependencies in from outside — over hard-wiring code to a global singleton internally. Injection makes it easy to swap implementations, like dropping in a mock for testing.
- A global variable in disguise. Strip away the
getInstance()wrapper and a Singleton is, at its core, a global variable — with every problem that comes with global variables: state that's hard to reason about, and changes from one place stepping on another.
So when does it still make sense
Knowing the trade-offs doesn't mean avoiding this pattern entirely — it comes down to the situation:
- You genuinely need a single, shared piece of global state, and that state is relatively stable — config loaded once at startup, for instance
- Creating the thing is expensive — a database connection pool, where recreating it repeatedly wastes real resources
- The project is small and testing needs are low, where over-engineering would cost more than it saves
Wrap-up
The core value of the Singleton Pattern is guaranteeing a class has at most one instance across the entire application, with one unified access point. When you implement it, watch out for race conditions under multi-threading, and duplicate triggers during async initialization.