The Singleton pattern — or why your app should have one delivery truck, not five
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.
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.
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
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.log(loggerFromAuth === loggerFromRouter); // false — two different trucksFix 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.
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
LogProcessor.getInstance().log({ msg: "sign-in attempt", userId });
// router.ts
LogProcessor.getInstance().log({ msg: "route changed", path });
console.log(LogProcessor.getInstance() === LogProcessor.getInstance()); // true, alwaysFix 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:
import { LogProcessor } from "./log-processor";
export const logProcessor = LogProcessor.getInstance();// 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.