#Singleton
A singleton guarantees there's only one instance of something — a DB connection, a config store, a logger — with one global access point. The problem it solves is coordination: if every part of the app opened its own database connection or kept its own copy of the config, you'd waste resources and the copies could disagree.
The mental model is a building with one reception desk. However many visitors arrive, and whichever door they come in by, they all end up talking to the same desk. In a class-based singleton, both doors — getInstance() and new — lead to the same object, which the class keeps in a private static field.
class Database {
static #instance = null;
constructor() {
if (Database.#instance) {
return Database.#instance; // `new` hands back the existing instance
}
this.connectedAt = Date.now();
Database.#instance = this;
}
static getInstance() {
return (Database.#instance ??= new Database());
}
query(sql) {
return `running: ${sql}`;
}
}
const a = Database.getInstance();
const b = new Database();
a === b; // true- When the class is defined, the private static field
Database.#instanceis created once, on the class itself, with the valuenull. Because it's#private, no code outside the class body can read it or reset it. Database.getInstance()evaluatesDatabase.#instance ??= new Database().??=only assigns when the left side isnullorundefined— it isnull, sonew Database()runs.- Inside that constructor
Database.#instanceis stillnull, so theifis skipped: it setsthis.connectedAtand storesthisin#instance. The??=then assigns the same object again (harmless) andgetInstancereturns it.ais that object. new Database()runs the constructor a second time.newhas already created a fresh empty object asthis, but now#instanceis set, so the constructor returns the existing instance. When a constructor explicitly returns an object,newuses that object instead ofthis— the fresh one is simply discarded.connectedAtis not overwritten.- So
a === bistrue: same reference, one connection. Without thereturnin the constructor,new Database()would quietly build a second instance and the guarantee would only hold for callers who remembered to usegetInstance().
ES modules are naturally singleton-ish
Since a module's code runs only once and is cached (see Modules are singletons), the simplest singleton in modern JS is just exporting an instance:
// logger.js
class Logger {
#logs = [];
log(msg) {
this.#logs.push(msg);
console.log(`[${new Date().toISOString()}] ${msg}`);
}
get count() {
return this.#logs.length;
}
}
export const logger = new Logger(); // every importer shares this one- The first time any file does
import { logger } from "./logger.js", the module loader fetches and evaluateslogger.jsonce: the class is defined andnew Logger()creates one object with an empty#logsarray. - The loader caches the evaluated module, keyed by its resolved URL (or file path in Node). Every later import of the same module gets the cached module — the body never runs again.
- So if
a.jscallslogger.log("hi")andb.jsreadslogger.count,b.jssees1: both files hold a reference to the same object. - You get the singleton guarantee without any static-field machinery — and the class itself isn't exported, so nobody else can make a second
Logger. - The guarantee is "one per resolved module", not "one per app". If two different versions of a package end up in
node_modules, or the same file is loaded once as ESM and once as CommonJS, you get two module instances and therefore two loggers.