A comprehensive guide to building high-throughput backends with Node.js. Learn about the Libuv event loop, writing custom Express middleware, and scaling with cluster processes.
Node.js has revolutionized backend software engineering by introducing an event-driven, non-blocking I/O model built on top of Google Chrome's V8 JavaScript engine. In this guide, we dive into how to architect, optimize, and scale production-grade Node.js and Express services.
The secret behind Node.js handling thousands of concurrent connections lies in the event loop provided by the Libuv C library. The loop executes in six main phases:
setTimeout() and setInterval().setImmediate().socket.on('close')). ┌───────────────────────────┐
│ Timers │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Pending Callbacks │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Poll Phase │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Check │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Close Callbacks │
└───────────────────────────┘
[!IMPORTANT]
process.nextTick()is not part of the Libuv event loop. It executes immediately after the current operation completes, regardless of the active phase. Abuse ofnextTickcan lead to I/O starvation.
In Express, middleware are functions that have access to the request object (req), response object (res), and the next middleware function in the application’s request-response cycle.
Here is how to write a secure custom authentication and rate limiting middleware:
// rateLimiter.js
const cache = new Map();
export function rateLimiter(req, res, next) {
const ip = req.ip;
const now = Date.now();
const limit = 100; // 100 requests
const windowMs = 60 * 1000; // 1 minute
if (!cache.has(ip)) {
cache.set(ip, [now]);
return next();
}
const timestamps = cache.get(ip).filter(t => now - t < windowMs);
timestamps.push(now);
cache.set(ip, timestamps);
if (timestamps.length > limit) {
return res.status(429).json({ error: "Too many requests. Please try again later." });
}
next();
}
Because Node.js runs on a single CPU core, it does not automatically exploit multi-core systems. The cluster module allows you to easily launch a cluster of child processes that share the server ports.
// server.js
import cluster from 'cluster';
import http from 'http';
import { cpus } from 'os';
import process from 'process';
const numCPUs = cpus().length;
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
// Fork workers matching CPU core count
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died. Spawning a replacement...`);
cluster.fork();
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer((req, res) => {
res.writeHead(200);
res.end('Hello from secure clustered worker!');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
By deploying clusters, you increase throughput and eliminate single points of failure at the process level.