Design PatternPublished 11/13/2024

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:

js
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 instance

A 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:

  1. Thread A checks whether instance is null — it is
  2. Thread B checks too, before A has finished creating one — also null
  3. A creates an instance
  4. 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:

js
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.