The Singleton Pattern in Node.js: Power, Pitfalls, and Performance Under Load
The Singleton pattern is one of the most recognizable patterns in software design — and one of the easiest to misuse.
In Node.js applications, especially in backends that handle concurrent API requests, a Singleton can quietly introduce race conditions, inconsistent behavior, and performance bottlenecks, even though Node.js is single-threaded.
This article breaks down:
- What a Singleton is (in JavaScript)
- Common use cases and misuses
- Why Singletons cause issues under load
- How to spot and fix risky usage
What Is a Singleton?
A Singleton ensures that a class has only one instance and provides a global access point to that instance.
Basic Example:
class Config {
constructor() {
this.settings = {
dbHost: 'localhost',
dbPort: 3306
};
}
get(key) {
return this.settings[key];
}
}
const config = new Config();
module.exports = config; // Shared instanceAny file that imports this module will get the same config object — which is often exactly what you want.
Common Real-World Use Cases
Singletons are widely used for:
- Application config
- Logger instances
- Database clients (with internal connection pooling)
- Redis or cache clients
- Utility classes
These use cases are generally safe, as long as the Singleton doesn’t store mutable data that changes per request.
When Things Go Wrong: Shared Mutable State
Let’s look at a problematic example: an email sending utility implemented as a Singleton.
class EmailSender {
constructor() {
this.recipients = [];
}
setRecipients(list) {
this.recipients = list;
}
send(message) {
this.recipients.forEach(email => {
console.log(`Sending "${message}" to ${email}`);
});
}
}
module.exports = new EmailSender();We can test Race condition of above class using below implementation where moduleB is updating email recipients before moduleA complete its task using shared send fucntion of EmailSender.
function moduleA() {
setTimeout(() => {
console.log('Module A setting recipients...');
emailSender.setRecipients(['alice@example.com', 'bob@example.com']);
// Wait a bit before sending — gives moduleB time to overwrite
setTimeout(() => {
console.log('Module A sending message...');
emailSender.send('Hello from A');
}, 50);
}, 10);
}
function moduleB() {
setTimeout(() => {
console.log('Module B setting recipients...');
emailSender.setRecipients(['carol@example.com']);
setTimeout(() => {
console.log('Module B sending message...');
emailSender.send('Hello from B');
}, 20);
}, 20);
}
moduleA();
moduleB();At first glance, this seems fine — until two users hit your API at the same time.
Request A:
- Starts at
10:00:00.000 - Sets recipients to
['a@example.com'] - Waits a bit
- Sends message:
'Welcome A!'
Request B:
- Starts at
10:00:00.001(just 1 ms later) - Sets recipients to
['b@example.com'] - Immediately sends:
'Welcome B!'
What Happens?
Because both requests are using the same shared instance, Request B overwrites the recipient list just before Request A sends its message. As a result:
Request A ends up sending
'Welcome A!'to'b@example.com'.
Visualizing the Race Condition
[Request A] setRecipients(['a@example.com'])
\
\
[Request B] ---------> setRecipients(['b@example.com'])
|
[Request A] --------------- |--> send('Welcome A!') // Sends to b@example.comThis is a classic race condition — both requests mutated the same object without realizing it.
Why It Happens in Node.js
Even though Node.js runs in a single thread, it uses asynchronous event-driven concurrency. That means multiple requests can be “in flight” at the same time, and they can all access the same shared objects — like Singletons.
If any of those Singletons maintain mutable state — especially request-specific data — you’re asking for bugs that are hard to track down.
How Singletons Become Bottlenecks
Here are three ways Singleton misuse shows up under load:
1. Shared Mutable State
As we just saw, using fields like .recipients to store data that changes per request creates unpredictable behavior. One request might overwrite another’s data.
2. Synchronous Blocking Code
If your Singleton performs CPU-heavy work (like loops or transformations) synchronously, it will block the event loop and stall other requests.
class TaskRunner {
run() {
const start = Date.now();
while (Date.now() - start < 1000); // Blocks event loop for 1 second
}
}3. Single Resource Bottlenecks
A Singleton that wraps a resource like a file stream, WebSocket, or non-pooled DB connection might serialize all traffic through it — slowing everything down.
When It Is Safe to Use a Singleton
As a rule of thumb:
If the data is tied to a single request or user, don’t store it inside a shared Singleton.
Refactoring Unsafe Singletons
Option 1: Make the Singleton Stateless
Instead of storing state inside the object, pass data into methods.
class EmailSender {
send(recipients, message) {
recipients.forEach(email => {
console.log(`Sending "${message}" to ${email}`);
});
}
}
module.exports = new EmailSender();Usage:
emailSender.send(['user@example.com'], 'Welcome!');This way, each request provides its own data, and nothing is stored or shared between calls.
Option 2: Create a New Instance Per Request
If you do need to store request-specific data, don’t use a Singleton. Just export the class and let each request create its own instance.
class EmailSender {
constructor(recipients) {
this.recipients = recipients;
}
send(message) {
this.recipients.forEach(email => {
console.log(`Sending "${message}" to ${email}`);
});
}
}
module.exports = EmailSender;Usage:
const EmailSender = require('./EmailSender');
const sender = new EmailSender(['user@example.com']);
sender.send('Welcome!');This isolates data to each request — no cross-contamination.
Summary
Final Thoughts
Singletons are powerful and appropriate in many cases — like for configuration, logging, or shared clients with internal pooling.
But when they start holding mutable, dynamic state, especially for requests or users, things get dangerous fast.
Always ask:
- Does this object store any data that changes per request?
- Could multiple requests hit this at the same time?
If the answer is yes, don’t reach for a Singleton. Use stateless patterns or instance-per-request designs. Your future self — and your users — will thank you.
