Sitemap

The Singleton Pattern in Node.js: Power, Pitfalls, and Performance Under Load

5 min readApr 22, 2025

--

Press enter or click to view image in full size

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 instance

Any 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.com

This 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

Press enter or click to view image in full size

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

Press enter or click to view image in full size

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.

--

--

Aditya Yadav
Aditya Yadav

Written by Aditya Yadav

Software Engineer who talks about tech concepts in web development https://www.linkedin.com/in/aditya-yadav-01/