← Writing
The Editorial · Engineering

The Singleton pattern — or why your app should have one delivery truck, not five

Issue 012Jul 23, 20264 min read

You wouldn't hire five delivery trucks to run one route — you'd get doubled-up drop-offs and missed pickups. Code has the same problem, and the fix is called the Singleton pattern: build a thing once, make everyone share it. Here it is, in one small, real example.

The issue

Picture one mailroom truck, picking up everything each morning. Now picture two departments each hiring their own truck for that same route — packages start going out twice, or not at all. That's the bug this issue is about, and the fix has a name: the Singleton pattern — build a thing once, make everyone share it.

the shape of a Singletonauth.tsrouter.tscheckout.tspayments.tsgetInstance()the one instancesame object, every callerhowever many callers ask, everyone gets handed the same thing back
Fig 1The pattern in general: however many parts of the app ask for it, everyone gets routed to the exact same shared instance — not a fresh copy each time.AI-generated figure

Here it is in real code. Say your frontend team has written a small logger: it sits in the browser, collects logs — errors, slow pages, whatever's worth knowing — and ships them off to your backend so you can actually see what's going wrong for real users.

The tool

Instead of a network request per log line, LogProcessor piles messages up in the browser and sends the whole batch to the server every 5 seconds or 20 messages, whichever comes first.

log-processor.tsTypeScript
class LogProcessor {
  private buffer: LogEntry[] = [];
  private timer: ReturnType<typeof setInterval>;

  constructor(private flushIntervalMs = 5000, private maxBatchSize = 20) {
    this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
  }

  log(entry: LogEntry) {
    this.buffer.push(entry);
    if (this.buffer.length >= this.maxBatchSize) this.flush();
  }

  private flush() {
    if (this.buffer.length === 0) return;
    const batch = this.buffer;
    this.buffer = [];
    sendToServer(batch); // the "truck" actually leaves
  }
}

Two copies, two problems

The sign-in screen and the router both need to log things, so both do the obvious thing:

auth.ts & router.tsTypeScript
// auth.ts
const logger = new LogProcessor();
logger.log({ msg: "sign-in attempt", userId });

// router.ts
const logger = new LogProcessor();
logger.log({ msg: "route changed", path });

It's tempting to think importing the same class twice somehow gives you the same object back. It doesn't — the module system shares the class, not what new does with it. Every new LogProcessor() call builds a fresh one, full stop:

console.logTypeScript
console.log(loggerFromAuth === loggerFromRouter); // false — two different trucks
new LogProcessor() — twicegetInstance() — twiceauth.tsrouter.tsauth.tsrouter.tsbuffer Abuffer Bshared buffertwo buffers, two flush clocksone buffer, one flush clock
Fig 2Two "build me a truck" calls quietly create two trucks on two schedules. One shared door instead means there's only ever one.AI-generated figure

Fix one: lock it in the class

Put one dispatcher in charge of building the truck. Ask for it: first time, it builds one; every time after, it just hands back the one that already exists. This doesn't lean on files or imports at all — the guarantee lives entirely inside the class itself.

log-processor.tsTypeScript
class LogProcessor {
  static #instance: LogProcessor;

  private buffer: LogEntry[] = [];
  private timer: ReturnType<typeof setInterval>;

  private constructor(private flushIntervalMs = 5000, private maxBatchSize = 20) {
    this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
  }

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

  log(entry: LogEntry) {
    this.buffer.push(entry);
    if (this.buffer.length >= this.maxBatchSize) this.flush();
  }

  private flush() {
    if (this.buffer.length === 0) return;
    const batch = this.buffer;
    this.buffer = [];
    sendToServer(batch);
  }
}

The constructor is private, so nobody outside can build a new one. Every caller goes through getInstance() instead — as many times, from as many files, as you like:

auth.ts & router.tsTypeScript
// auth.ts
LogProcessor.getInstance().log({ msg: "sign-in attempt", userId });

// router.ts
LogProcessor.getInstance().log({ msg: "route changed", path });

console.log(LogProcessor.getInstance() === LogProcessor.getInstance()); // true, always

Fix two: lock it in one file

Same class, unchanged. This time, only one file is ever allowed to call getInstance() — every other file just imports the result:

log-processor-singleton.tsTypeScript
import { LogProcessor } from "./log-processor";

export const logProcessor = LogProcessor.getInstance();
import { log } from "./log-processor"auth.ts — firstrouter.tscheckout.tscache hit — no re-runmodule body runs — oncebuffer = [ ]; timer = nullmodule cache → { log }same buffer, every importone module record, however many files import it
Fig 3The first import runs log-processor-singleton.ts and resolves getInstance() once. Every import after that just gets handed that same logProcessor.AI-generated figure
auth.ts & router.tsTypeScript
// auth.ts
import { logProcessor } from "./log-processor-singleton";
logProcessor.log({ msg: "sign-in attempt", userId });

// router.ts
import { logProcessor } from "./log-processor-singleton";
logProcessor.log({ msg: "route changed", path });

Same object either way. Fix one puts the guarantee in the class, so it holds no matter how the file gets loaded. Fix two puts it in one file, so nobody else even needs to know getInstance() exists.

Where "just one" can bite you

A Singleton isn't a clever trick — it's a promise (exactly one of this, shared by everyone) enforced by code instead of hoped for. Default to the one-file version. Reach for the dispatcher when you need more control. And the moment people need their own private copy, that's a different problem, not a Singleton in disguise.

All issues