Skip to main content

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, and async/await instead 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 in Module._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

js
// 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 holds

Node 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

PatternCore mechanismAsync fitReal libraryWhen to use
Modulemodule.exports + closureBothBuilt-inAlways, every file
Singletonrequire() cacheBothWinston loggerOne global resource
Observer/PubSubEventEmitterAsyncSocket.ioEvent streams
Middlewarenext() function chainAsyncExpressHTTP pipelines
FactoryFunction returning new instancesBothMongoose modelsDynamic types
Promise Chain.then() / async/awaitAsyncAxiosData 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:

js
// 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 too

Node 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:

js
// 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:

js
// 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:

js
// 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:

js
// 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 EventEmitter for 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:

js
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

js
// 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

js
// 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

js
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?