What are the most common design patterns in Node.js?
Design patterns in Node.js are reusable solutions shaped around the event loop, non-blocking I/O, and require() caching. The six most common ones in production codebases are Module, Singleton, Observer/PubSub, Middleware, Factory, and Promise Chain.
Theory
TL;DR
- Node.js patterns adapt classic ideas to async callbacks, streams, and the event loop. Think: modules are kitchen stations (one job each), singletons are the shared chef's knife, pubsub is the expediter calling orders across the kitchen.
- Main difference from Java or C#: these patterns use
require()caching,EventEmitter, andasync/awaitinstead of class hierarchies and blocking constructors. - Rule: if more than 3 components need to interact, use PubSub instead of direct calls.
- Node caches every
require()result inModule._cache, so any exported object is singleton-like for free. - Middleware is just function chaining via
next()- that's the entire mental model for Express pipelines.
Quick example
// Module pattern - natural in Node.js (counter.js)
let count = 0; // Private - nothing outside can read or mutate this directly
module.exports = {
inc: () => ++count,
get: () => count
};
// app.js
const counter = require('./counter');
counter.inc();
console.log(counter.get()); // 1
console.log(counter.count); // undefined - encapsulation holdsNode caches the module on first require(), so count persists across all files that import this. That's the Module and Singleton combo in 8 lines, no class required.
Why Node.js patterns look different from OOP languages
Classic GoF patterns assume you create objects through constructors and manage their lifecycle manually. Node skips most of that. The module cache handles singleton behavior automatically. EventEmitter covers pub/sub without a dedicated bus library. And async/await replaces the complex threading those patterns were originally designed to tame.
So instead of a Singleton class with a private constructor and a static getInstance() method, you just export an instance. That's it. The pattern is still there - it just wears different clothes.
When to use which pattern
- Encapsulate private state - Module pattern. Default for every file you write.
- Share one instance across the app - Singleton. DB connections, loggers, config objects.
- Decouple event producers from consumers - Observer/PubSub. Order processing, WebSockets, user events.
- Chain request processing - Middleware. Express routes, auth, logging, rate limiting.
- Create objects without hardcoding a class - Factory. DB adapters, plugins, config-driven types.
- Handle sequential async steps - Promise Chain or async iterators. API pipelines, data transforms.
Comparison table
| Pattern | Core mechanism | Async fit | Real library | When to use |
|---|---|---|---|---|
| Module | module.exports + closure | Both | Built-in | Always, every file |
| Singleton | require() cache | Both | Winston logger | One global resource |
| Observer/PubSub | EventEmitter | Async | Socket.io | Event streams |
| Middleware | next() function chain | Async | Express | HTTP pipelines |
| Factory | Function returning new instances | Both | Mongoose models | Dynamic types |
| Promise Chain | .then() / async/await | Async | Axios | Data flows |
How Node.js handles this internally
V8 compiles a module the first time require() is called, then stores the result in Module._cache - a C++ hash table. Every subsequent require() of the same path returns the cached object without re-executing the file. That's free singleton behavior, built into the runtime.
EventEmitter uses libuv's epoll/kqueue for non-blocking event queues. When you call .emit(), it fires registered callbacks from the event loop without spawning threads or blocking. Express's middleware stack runs via next() recursion inside its Router, yielding control back to libuv between each step.
Common mistakes
Mutating shared module state and expecting per-import isolation:
// Wrong - all importers share this same cache object
let cache = {};
module.exports = {
set: (key, val) => { cache[key] = val; },
get: (key) => cache[key]
};
// One test sets cache['user'] = 'Alice', next test sees it tooNode caches the module once. Every file that imports this shares the same cache. If isolation matters, return a factory function that creates a fresh closure per call.
Forgetting to remove EventEmitter listeners:
// Wrong - handler stays in memory after socket closes
socket.on('data', handler);
socket.end();
// Right
const handler = (data) => processData(data);
socket.on('data', handler);
socket.on('close', () => socket.removeListener('data', handler));Lingering listeners are the most common cause of memory leaks in Node apps. Node warns you when a single emitter exceeds 10 listeners by default - that warning is not noise, it's a signal.
Assuming singleton works across a cluster:
// Works in a single process, breaks in PM2 cluster mode
if (Database.instance) return Database.instance;
Database.instance = new Database();Each worker process has its own Module._cache. Singleton is per-process, not per-cluster. For truly shared state across workers, use IPC via the cluster module or an external store like Redis.
Middleware without error propagation:
// Wrong - unhandled throw hangs the request
app.use((req, res, next) => {
parseBody(req); // throws on bad JSON
next();
});
// Right
app.use((req, res, next) => {
try {
parseBody(req);
next();
} catch (e) {
next(e); // Express error handler takes it from here
}
});Factory that secretly returns the same instance:
// This is a singleton, not a factory
const getDB = () => db; // Same object every call
// Actual factory - new instance per call
const createDB = (config) => new DatabaseAdapter(config);If your factory always returns the same object, it's a singleton with extra steps. Factories produce new instances - that's the definition.
Real-world usage
- Express - Middleware for auth, logging, and rate limiting in almost every production Node app.
- Socket.io - PubSub via
EventEmitterfor real-time features: chat, live dashboards, collaborative tools. - Winston - Singleton logger created once and imported everywhere via the
require()cache. - Mongoose - Factory for dynamic models:
mongoose.model('User', schema)returns a new constructor based on config. - Node Redis - Singleton client with built-in PubSub channels for distributed messaging across processes.
I've seen codebases where teams didn't use any patterns intentionally and ended up with accidental singletons (shared mutable state), accidental middleware (functions passing callbacks), and accidental factories (functions returning different object shapes). Naming and recognizing patterns is what separates maintainable code from a pile of functions that happen to work.
Follow-up questions
Q: How does Node's module cache make every module a singleton?
A: First require() executes the file and stores exports in Module._cache. All later require() calls return that cached value without re-running the file. One execution, one object, per process.
Q: What's the difference between EventEmitter PubSub and Redis PubSub?
A: EventEmitter is in-memory and works only within one Node process. Redis PubSub is distributed - messages reach subscribers across multiple servers or processes, and the broker persists outside your app.
Q: How do you implement middleware that times out after 5 seconds?
A: Set a timer and clear it when the response finishes:
const timeout = (ms) => (req, res, next) => {
const timer = setTimeout(() => next(new Error('Request timeout')), ms);
res.on('finish', () => clearTimeout(timer));
next();
};
app.use(timeout(5000));Q: In a PM2 cluster, how do you share a singleton connection across workers?
A: The master process creates the connection and passes messages to workers via cluster.worker.send() and process.on('message'). Or use an external connection pool like pg-pool that manages per-process connections.
Q: What happens if you delete a module from require.cache?
A: The next require() re-executes the file and creates a fresh instance. Your singleton is gone. Hot-reload tools use this intentionally - it's also a common source of confusion in tests that clear the cache between runs.
Q: Why avoid class-based singletons in Node.js?
A: require() caching handles it already. A class with a private constructor adds boilerplate without any real benefit. module.exports = new MyService() does the same thing in one line and reads more clearly.
Examples
Module and Singleton: shared logger across the app
// logger.js - one instance for the entire app
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.simple(),
transports: [new winston.transports.Console()]
});
module.exports = logger; // Cached after first require
// server.js
const logger = require('./logger');
logger.info('Server started on port 3000');
// routes/users.js
const logger = require('./logger'); // Same object, no new instance created
logger.info('GET /users called');Both files share the exact same logger. No class, no static property - just the module cache. This is how Winston and most logger setups work in real projects.
Middleware chain: logging and auth in Express
// middleware/index.js
const logRequest = (req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next(); // Always pass to next handler
};
const authenticate = (req, res, next) => {
const token = req.headers.authorization;
if (token === 'Bearer secret-token') {
req.user = { id: 1, role: 'admin' };
next(); // Attach user and continue
} else {
res.status(401).json({ error: 'Unauthorized' }); // Chain stops here
}
};
module.exports = { logRequest, authenticate };
// app.js
const express = require('express');
const { logRequest, authenticate } = require('./middleware');
const app = express();
app.use(logRequest); // Runs for every request
app.use('/api', authenticate); // Runs for /api/* only
app.get('/api/profile', (req, res) => {
res.json({ user: req.user });
});
// GET /api/profile with correct token:
// Logs: 2024-01-15T10:00:00.000Z GET /api/profile
// Returns: { user: { id: 1, role: "admin" } }
// Without token: 401 { error: 'Unauthorized' }The chain stops at authenticate if the token is wrong. The route handler never runs. That's the Chain of Responsibility behavior - each step decides whether to continue or terminate.
Observer pattern: order service with multiple subscribers
const EventEmitter = require('events');
class OrderService extends EventEmitter {
async placeOrder(order) {
const saved = { ...order, id: Date.now(), status: 'placed' };
this.emit('orderPlaced', saved); // Fire once, all handlers react
return saved;
}
}
const orderService = new OrderService();
// Three independent handlers - OrderService knows nothing about them
orderService.on('orderPlaced', (order) => {
console.log(`Email sent for order ${order.id}`);
});
orderService.on('orderPlaced', (order) => {
console.log(`Stock updated for: ${order.item}`);
});
orderService.on('orderPlaced', (order) => {
console.log(`Analytics: order ${order.id} tracked`);
});
(async () => {
await orderService.placeOrder({ item: 'Laptop', qty: 1 });
// Output:
// Email sent for order 1705312800000
// Stock updated for: Laptop
// Analytics: order 1705312800000 tracked
})();Adding a fourth handler - say, a Slack notification - requires zero changes to OrderService. The producer emits, the consumers decide what to do with it. That decoupling is exactly why this pattern scales well when systems grow.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.