The problem
Some objects should exist only once in a process: a database connection pool, a metrics registry, an in-process cache, a configuration snapshot. A plain class doesn't enforce that. If any code can call new ConnectionPool(), the repositories in one service each open their own pool, a database sized for 10 connections from this service gets 30, and every pool keeps its own idea of which connections are idle. Passing one shared object down through every layer that needs it feels like plumbing, and that is when a singleton looks attractive.
How it works
A Singleton makes a class responsible for having at most one instance, and for handing that instance to whoever asks for it (Gamma, Helm, Johnson and Vlissides, 1994). The pattern has a single participant, the Singleton class, and clients reach the instance only through it:
- the constructor is private, so no other code can create an instance;
- a static field holds the one instance;
- a static access method, here
getInstance(), creates the instance on the first call (lazy) or returns one built when the class loaded (eager), and returns that same object on every later call.
A singleton does two jobs at once. One instance protects a scarce resource and keeps shared state consistent, and that part is often exactly what you need. Global access lets any code reach the object without being handed it, and that is the controversial part. It is global mutable state under another name: a class's dependencies disappear into its method bodies, tests inherit whatever the previous test left behind, and the order in which things initialise starts to matter. You can keep the first job and drop the second: create one instance when the program starts and pass it to the code that needs it.
Lazy or eager. Eager creation, when the class or module loads, is simple and has no race, but every run pays the start-up cost and a failure surfaces at load time. Lazy creation moves the cost to the first caller, and that is where the concurrency bugs live: two threads, or two asynchronous callers, can both see "no instance yet".
The asynchronous version of the race. In JavaScript and TypeScript, opening a pool returns a promise. A getInstance() that awaits the connection before it stores anything leaves a gap in which a second caller also finds the field empty and opens a second pool: two pools and 20 connections in the diagram's third step. Store the creation promise in the field before awaiting it, and every caller awaits the same promise. If creation fails, clear the field, or every later caller gets the cached rejection.
Relatives. Factory Method and Abstract Factory decide which class gets instantiated; Singleton decides how many instances exist. Concrete factories and facades often need only one instance, so they are frequently written as singletons, but that is an implementation choice, not part of those patterns. Flyweight shares many instances, one per distinct shared state, through a factory; a registry that hands out one instance per name sits between the two (Python's logging.getLogger(name) returns the same logger for the same name). Modern languages have absorbed much of the pattern: a module is evaluated once, Kotlin's object declaration is a singleton the compiler builds for you, and dependency injection (DI) containers manage a singleton lifetime.
Code
A ConnectionPool with a private constructor and a static getInstance() that caches the creation promise. A fake connect() counts how many pools get opened, and the usage at the end makes two concurrent calls, as in step 3, then a later one, as in step 2.
// connection-pool.ts: run with `node connection-pool.ts` (Node 22.18+ strips the types)
import assert from 'node:assert/strict';
let poolsOpened = 0; // what the database would count
async function connect(size: number): Promise<string[]> {
poolsOpened++; // a fake driver: opening takes a while
await new Promise((resolve) => setTimeout(resolve, 50));
return Array.from({ length: size }, (_, i) => `conn ${poolsOpened}.${i + 1}`);
}
class ConnectionPool {
private static pending: Promise<ConnectionPool> | undefined;
readonly connections: string[];
private constructor(connections: string[]) { // `new` only from inside the class
this.connections = connections;
}
// Store the promise *before* awaiting it: a caller that arrives while the pool
// is still opening gets the same promise instead of opening a second pool.
// The broken version awaits first and stores the result afterwards:
// ConnectionPool.instance ??= await ConnectionPool.open();
// Two concurrent calls both find no instance: poolsOpened === 2, 20 connections.
static async getInstance(): Promise<ConnectionPool> {
ConnectionPool.pending ??= ConnectionPool.open();
return ConnectionPool.pending;
}
private static async open(): Promise<ConnectionPool> {
try {
return new ConnectionPool(await connect(10));
} catch (err) {
ConnectionPool.pending = undefined; // don't cache a failure: the next call retries
throw err;
}
}
}
// OrdersRepo and UsersRepo ask at the same moment; ReportsJob asks later.
const [a, b] = await Promise.all([ConnectionPool.getInstance(), ConnectionPool.getInstance()]);
const c = await ConnectionPool.getInstance();
assert.equal(poolsOpened, 1);
assert.ok(a === b && b === c);
console.log(`pools opened: ${poolsOpened}, connections: ${a.connections.length}, same object: ${a === b && b === c}`);
// pools opened: 1, connections: 10, same object: true
The fix works because an async function runs synchronously up to its first await: the ??= assignment happens during the first call, before any other caller gets a turn. The broken line checks the field, then awaits, then assigns, and two concurrent calls both fall into the gap between the check and the assignment.
When to use it
- A resource that must be unique within the process and is expensive or limited: a connection pool, a thread pool, a metrics registry, an in-process cache. Even then, prefer creating it once where the program is assembled (
main()or the DI container) and passing it in. Keep a staticgetInstance()for places where you can't pass things in, such as a library's entry point or a framework callback. - Objects that model something the process has exactly one of. The JDK's
Runtime.getRuntime()returns the singleRuntimeobject every Java application has. - Not for stateless helpers: plain functions, or a module of functions, do that job without an instance.
- Not when "only one" has to hold across the whole system. One active scheduler or writer among many replicas is leader election, and one source of settings for every instance is an external configuration store.
Trade-offs
- Hidden dependencies. A class that calls
ConnectionPool.getInstance()inside a method needs a database, but nothing in its constructor says so. Miško Hevery's 2008 post calls singletons "pathological liars" for this reason and argues for passing dependencies in through constructors instead. - Tests that leak into each other. The instance outlives each test, so state left by one test changes the next, and a test can't swap in a fake pool without reaching into the class. A static
resetForTests()hook is a smell: it puts a backdoor into production code and still fails once tests run in parallel. Inject the instance instead, and each test builds what it needs. - Unique only within a boundary. A Java singleton is one per class loader (Spring's documentation contrasts this with its own per-container singletons), a JavaScript one is one per loaded module, and a Python one is one per interpreter. Scale the service to 4 replicas and you have 4 pools and 40 connections; with PostgreSQL's default
max_connectionsof 100, ten such replicas would use the whole budget. - Lifecycle. Someone has to close the pool at shutdown, after everything that uses it has stopped. A container that created a singleton can dispose of it; a hand-written static is usually left open until the process exits.
- Concurrency. Lazy creation needs a guard that is safe for threads or for concurrent promises, and the instance itself must be safe to use from every caller at once.
Implementation notes
- Java has three safe forms. Eager: a
private static finalfield set where it is declared; the JVM runs class initialisation under a per-class lock, so exactly one instance is built. Lazy: the holder idiom, a nested class whose static field holds the instance; the JVM initialises the nested class only whengetInstance()first reads that field. Enum:enum ConnectionPool { INSTANCE; ... }, the form Joshua Bloch recommends in Effective Java (Item 3) and shows in the book's example code. Double-checked locking needs the field to bevolatile, which is reliable from Java 5 on (an immutable object with onlyfinalfields is the one exception); without it, another thread can see the reference before the object behind it is fully constructed. - Java's loopholes. Deserialising a class-based singleton produces a new object unless the class defines
readResolve()to return the existing instance. Reflection withsetAccessible(true)can still call a private constructor where module access allows it, so defensive code throws from the constructor when an instance already exists. An enum closes both gaps: a constant is serialised as its name and resolved back to the same constant, andConstructor.newInstancerefuses to create enum instances. The cost is that an enum can't extend another class. - Other runtimes. In C#,
Lazy<T>is thread-safe by default, so astatic readonly Lazy<ConnectionPool>creates exactly one pool. In Go,sync.OnceValue(Go 1.21 and later) wraps a constructor so that it runs once, even when called concurrently. In Kotlin, anobjectdeclaration is initialised thread-safely on first access. - TypeScript's
privateis a compile-time check. The TypeScript handbook notes thatprivateis enforced only during type checking, so plain JavaScript, or a cast, can still callnew ConnectionPool(). If that matters, don't export the class at all: export the accessor or the instance. - Module scope is the idiomatic singleton in JavaScript and Python. An ES module's body runs once, however many modules import it, so an exported
poolis shared by every importer. Node.js keys that cache by resolved URL, so a different query string loads a second copy, and keys CommonJS modules by resolved file name, so two copies of a package innode_modulesgive two instances. Python keeps every imported module insys.modules, so a module-levelpool = ConnectionPool()runs once per interpreter, unless something callsimportlib.reload()or deletes the entry. - DI containers. Spring beans are singleton-scoped by default: one instance per container and bean definition. In .NET,
AddSingletonregisters a service that the container creates on first request (or an instance you supply) and reuses for every later request. Such a service must be thread-safe and must not hold on to a scoped service; in the Development environment the default service provider checks for that mistake. - At cluster scale. "One" across replicas needs coordination rather than a static field: a lease in a shared store, and a plan for when its holder dies, which is leader election. To cap database connections across many replicas, put a server-side pooler such as PgBouncer, or a managed database proxy, in front of the database.