Singleton in JavaScript & TypeScript
Why Node.js developers almost never need a 'Double-Check Lock.' Explore the Module Singleton pattern and how ESM caching handles everything for you.
In the previous post, we saw how the Singleton pattern works and how multithreading in languages like Java can break it. However, if you are a JavaScript or TypeScript developer, you might be wondering: "Do I really need all this complex locking code?"
The short answer is No. Because of how JavaScript's execution model and module system work, implementing a Singleton is significantly simpler and safer by default.
Why JS is Different: The Event Loop
JavaScript is primarily single-threaded. While it can handle 1,000 requests concurrently, it executes the actual JavaScript code on a single "Event Loop."
This means that unlike Java, where two threads might reach the if (instance == null) check at the exact same time, in JavaScript, only one piece of code is running at any given millisecond. There is no "race" for creation.
The Modern Way: The Module Singleton
In modern JS/TS (ES Modules), when you import a file, the code inside that file is executed exactly once. The result is then cached by the system. Every other file that imports it gets the same cached result.
This is the most "idiomatic" way to create a Singleton in Node.js:
// database.ts
class Database {
private connection: string;
constructor() {
this.connection = "Connected to Postgres at :5432";
console.log("Database Instance Created!");
}
public query(sql: string) {
console.log(`Executing: ${sql} using ${this.connection}`);
}
}
// We create the instance HERE and export it
export const db = new Database();Usage:
import { db } from './database';
db.query("SELECT * FROM users"); // This uses the shared instanceNo matter how many files import db, they all get the same object. The "Lazy Initialization" happens automatically when the first file imports it.
The TypeScript Class Pattern
If you prefer a more traditional OOP style or want to strictly prevent developers from using new Database(), you can use TypeScript's access modifiers:
class Logger {
private static instance: Logger;
private constructor() {
console.log("Initializing Logger...");
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(msg: string) {
console.log(`[LOG]: ${msg}`);
}
}
// Usage
const logger = Logger.getInstance();What about Worker Threads?
JavaScript does have multi-threading via Worker Threads (Node.js) or Web Workers (Browser).
The Key Difference: Unlike Java threads that share the same memory (Heap), JS Workers have isolated memory. When you spin up a worker, it gets its own fresh environment.
If both the main thread and a worker thread import your db module, they will each have their own separate Singleton instance in their own memory space. Because they don't share memory, they can't have a race condition!
Summary
If you are building a standard Node.js or React application:
- Don't use Double-Check Locking: It adds unnecessary complexity and overhead.
- Use Module Exports: It's the cleanest and most efficient way to handle shared resources.
- Private Constructors: Use them if you want to enforce the pattern in large teams, but the behavior is the same.
In the next part, we will dive deep into the world of multi-threaded languages like Java and explore Double-Check Locking, which is essential for senior engineering roles in enterprise environments.
Practice what you just read.
Keep reading