Singleton ensures a class has exactly one instance and gives everyone a single way to reach it. It exists for things that must be shared to work correctly: a connection pool, a configuration object, an in-memory cache, a rate limiter's counters, the cursor of a load balancer.

Difficulty: ★☆☆☆☆. Popularity: ★★★★☆. It is the easiest pattern to write and the easiest to overuse, so this chapter spends as much time on its limits as on its code.

The problem: state that only works if it is shared

A round-robin load balancer sends each request to the next server in a list: A, B, C, A, B, C. It needs one piece of state, the position of the next server. Here is a version that looks correct:

type Backend = { url: string; healthy: boolean };

class RoundRobinBalancer {
  private cursor = 0;
  constructor(private readonly backends: Backend[]) {}

  next(): Backend {
    const backend = this.backends[this.cursor % this.backends.length]!;
    this.cursor++;
    return backend;
  }
}

const servers: Backend[] = [
  { url: "http://10.0.0.1", healthy: true },
  { url: "http://10.0.0.2", healthy: true },
  { url: "http://10.0.0.3", healthy: true },
];

// Each request handler creates its own balancer...
function handleRequest() {
  return new RoundRobinBalancer(servers).next().url;
}

console.log([1, 2, 3, 4].map(handleRequest));
// [ "http://10.0.0.1", "http://10.0.0.1", "http://10.0.0.1", "http://10.0.0.1" ]

Every request gets a fresh balancer with cursor = 0, so every request goes to the first server. The algorithm is right; the problem is that its state must be shared and nothing enforces that.

The classic Singleton

The textbook fix makes the constructor private and exposes one static accessor that creates the instance on first use:

class LoadBalancer {
  private static instance: LoadBalancer | undefined;
  private readonly backends: Backend[] = [];
  private cursor = 0;

  private constructor() {} // `new LoadBalancer()` is now a compile error outside this class

  static getInstance(): LoadBalancer {
    return (LoadBalancer.instance ??= new LoadBalancer());
  }

  register(url: string) {
    if (!this.backends.some((b) => b.url === url)) this.backends.push({ url, healthy: true });
  }

  markHealth(url: string, healthy: boolean) {
    const backend = this.backends.find((b) => b.url === url);
    if (backend) backend.healthy = healthy;
  }

  /** Round robin with the modulo trick, skipping unhealthy backends. */
  next(): Backend {
    const count = this.backends.length;
    for (let attempt = 0; attempt < count; attempt++) {
      const backend = this.backends[this.cursor]!;
      this.cursor = (this.cursor + 1) % count;
      if (backend.healthy) return backend;
    }
    throw new Error("No healthy backend available");
  }
}

Two details in next() are worth noticing. (cursor + 1) % count wraps the index back to 0 after the last server, which is the whole round-robin algorithm in one expression, and keeps the number small instead of growing forever. The loop tries at most count servers, so a fully unhealthy pool fails fast instead of spinning.

Every module that calls getInstance() now shares the same cursor:

// startup.ts
const lb = LoadBalancer.getInstance();
["http://10.0.0.1", "http://10.0.0.2", "http://10.0.0.3"].forEach((url) => lb.register(url));

// request handlers, anywhere in the app
const route = () => LoadBalancer.getInstance().next().url;

console.log([1, 2, 3, 4].map(route));
// [ "http://10.0.0.1", "http://10.0.0.2", "http://10.0.0.3", "http://10.0.0.1" ]

LoadBalancer.getInstance().markHealth("http://10.0.0.2", false);
console.log([1, 2, 3].map(route));
// [ "http://10.0.0.3", "http://10.0.0.1", "http://10.0.0.3" ]

console.log(LoadBalancer.getInstance() === LoadBalancer.getInstance()); // true

The JavaScript way: a module is already a singleton

Node.js and every bundler evaluate a module once and cache it. Everything a module exports is shared by every importer. So in JavaScript, the simplest singleton is a module with state:

// load-balancer.ts
const backends: Backend[] = [];
let cursor = 0;

export function register(url: string) {
  if (!backends.some((b) => b.url === url)) backends.push({ url, healthy: true });
}

export function nextBackend(): Backend {
  for (let attempt = 0; attempt < backends.length; attempt++) {
    const backend = backends[cursor]!;
    cursor = (cursor + 1) % backends.length;
    if (backend.healthy) return backend;
  }
  throw new Error("No healthy backend available");
}

Or export one instance of a normal class, which keeps the class easy to test with separate instances:

export class Balancer {
  /* same body as LoadBalancer, with a public constructor */
}
export const balancer = new Balancer();

This is how most real code does it: export const prisma = new PrismaClient(), export const logger = pino(), export const redis = new Redis(url). You get "one instance" from the module system and keep the class constructible in tests.

Singletons are per process, not per system

This is the part interviews like to probe. A singleton is unique within one JavaScript runtime. In production you rarely have one:

  • Node's cluster module or PM2 runs one process per CPU core. Four processes means four balancers with four cursors.
  • Kubernetes runs several pods. Each has its own singleton.
  • Serverless platforms start a new instance per concurrent request burst.
  • Worker threads have their own module graphs.

For a load balancer cursor, per-process state is fine: each process still spreads its own traffic evenly. For anything that must be globally unique (a rate limit per user, a counter, a lock, "only one cron job runs"), an in-memory singleton is the wrong tool. Move the state to Redis, the database, or a coordination service.

There is one more trap specific to JavaScript tooling: hot reloading in development re-evaluates modules, which can create a new database client on every file save until the connection limit is hit. The common fix caches the instance on globalThis:

import { Pool } from "pg";

const globalForDb = globalThis as unknown as { pool?: Pool };

export const pool = globalForDb.pool ?? new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

if (process.env.NODE_ENV !== "production") globalForDb.pool = pool;

A related problem is the dual package hazard: if your app ends up with two copies of the same library in node_modules (or loads its CommonJS and ESM builds side by side), each copy has its own module-level singleton.

Why singletons have a bad reputation

The pattern itself is harmless. The trouble comes from getInstance() being callable from anywhere:

  • Hidden dependencies. A function that calls LoadBalancer.getInstance() inside its body depends on it without saying so in its signature. You discover the dependency only when it breaks.
  • Hard to test. Tests share the instance, so state leaks between tests unless you add a reset method, which is test code in production code.
  • Global mutable state. Any module can change it at any time, which makes bugs non-local.

The cure is to keep the single instance but drop the global access: create the instance once at startup and pass it to what needs it. That is what dependency injection containers do. In NestJS, every provider is a singleton by default, and you receive it through the constructor instead of a static call:

class ProxyService {
  constructor(private readonly balancer: Pick<LoadBalancer, "next">) {}
  target(path: string) {
    return `${this.balancer.next().url}${path}`;
  }
}

// In a test, pass a fake. In the app, pass LoadBalancer.getInstance() once.
const service = new ProxyService({ next: () => ({ url: "http://test", healthy: true }) });
console.log(service.target("/health")); // http://test/health

When to use it, and when not to

Use a single shared instance for:

  • Expensive resources that must be pooled: database pools, HTTP agents with keep-alive, Redis clients.
  • Process-wide state that is correct per process: an in-memory cache, a load balancer cursor, metrics registries.
  • Read-only configuration loaded once at startup.

Avoid it for:

  • State that must be consistent across processes or machines. Use shared storage.
  • Things that are cheap to create and have no shared state. Just create them.
  • Anything you will want two of later (two tenants, two regions, a primary and a replica database).

Common pitfalls

  1. Calling getInstance() deep inside business logic. Resolve it at the edges and pass it in.
  2. Lazy creation with async setup. getInstance() cannot await a connection. Initialise async resources at startup, or store the promise (instance ??= connect()) so concurrent callers share one attempt.
  3. Assuming one instance in a cluster. Every process, pod and serverless instance has its own.
  4. Singletons that hold request data. A shared object that stores "the current user" will mix users under concurrent requests. Use AsyncLocalStorage for request-scoped context.

Exercises

  1. Write a test that registers three backends, marks one unhealthy, calls next() six times and checks the exact order. What do you need to add so the test does not leak state into the next test?
  2. Implement weighted round robin: a backend with weight 3 should get three times the traffic of one with weight 1, spread out rather than in bursts.
  3. Run the balancer under Node's cluster module with two workers and log which backend each worker picks. Explain the output.
  4. Replace the in-memory cursor with a Redis INCR so all processes share one sequence. What did you gain and what did you lose?