Node.js Performance Optimization: Profiling, Clustering, and Caching
Node.js can handle tens of thousands of concurrent connections — but only if you avoid common pitfalls. This guide covers how to identify bottlenecks and fix them.
Understanding the Event Loop
Node.js uses a single-threaded event loop. This is its superpower for I/O-heavy workloads, but also its Achilles' heel for CPU-heavy tasks.
// ✅ Non-blocking — Event loop stays free
app.get('/users', async (req, res) => {
const users = await db.query('SELECT * FROM users'); // I/O, yields to event loop
res.json(users);
});
// ❌ Blocking — Event loop is frozen for the duration
app.get('/compute', (req, res) => {
const result = heavyComputation(); // Blocks ALL other requests!
res.json(result);
});
Detecting Event Loop Lag
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log({
min: h.min / 1e6 + 'ms',
max: h.max / 1e6 + 'ms',
mean: h.mean / 1e6 + 'ms',
p99: h.percentile(99) / 1e6 + 'ms',
});
h.reset();
}, 5000);
If P99 is consistently > 100ms, you have event loop blocking.
CPU Profiling
Built-in Node.js Profiler
# Start with profiling enabled
node --prof app.js
# After some load, kill the process
# Process the profile file
node --prof-process isolate-0xXXXXXX-v8.log > profile.txt
# Look for "Bottom up" section to find hot functions
Clinic.js — Visual Profiling
npm install -g clinic
# Profile for bottlenecks
clinic doctor -- node app.js
# Detailed flame graph
clinic flame -- node app.js
# Event loop delays
clinic bubbleprof -- node app.js
CPU-Intensive Work: Worker Threads
// worker-thread.js
const { parentPort, workerData } = require('worker_threads');
function heavyCompute(data) {
// Expensive CPU work here
let result = 0;
for (let i = 0; i < data.iterations; i++) {
result += Math.sqrt(i);
}
return result;
}
parentPort.postMessage(heavyCompute(workerData));
// main.js
const { Worker } = require('worker_threads');
function runInWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker-thread.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}
// This no longer blocks the event loop
app.get('/compute', async (req, res) => {
const result = await runInWorker({ iterations: 1_000_000 });
res.json({ result });
});
Clustering: Use All CPU Cores
// cluster.js
const cluster = require('cluster');
const os = require('os');
const numCPUs = os.cpus().length;
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} running`);
console.log(`Forking ${numCPUs} workers...`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork(); // Auto-restart
});
} else {
// Workers run the actual server
const app = require('./app');
app.listen(3000, () => {
console.log(`Worker ${process.pid} started`);
});
}
In production, use PM2 instead:
npm install -g pm2
# Start in cluster mode (auto-detects CPU count)
pm2 start app.js -i max
# Monitor
pm2 monit
pm2 logs
Caching with Redis
// cache.ts
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
// Cache middleware
export function cache(ttlSeconds = 60) {
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
const cached = await redis.get(key);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
// Override res.json to cache the response
const originalJson = res.json.bind(res);
res.json = async (data) => {
await redis.setEx(key, ttlSeconds, JSON.stringify(data));
res.setHeader('X-Cache', 'MISS');
return originalJson(data);
};
next();
};
}
// Usage
app.get('/products', cache(300), getProducts); // Cache 5 minutes
app.get('/user/:id', cache(60), getUserById); // Cache 1 minute
Cache Invalidation
// Delete cache when data changes
async function updateProduct(id, data) {
await db.update('products', id, data);
// Invalidate related caches
await redis.del(`cache:/products/${id}`);
await redis.del('cache:/products'); // Also invalidate list
}
// Pattern-based invalidation
const keys = await redis.keys('cache:/products*');
if (keys.length) await redis.del(keys);
Database Query Optimization
N+1 Problem — The #1 Performance Killer
// ❌ N+1 Problem — 1 query to get orders + N queries for each user
const orders = await Order.findAll(); // 1 query
for (const order of orders) {
const user = await User.findById(order.userId); // N queries!
order.user = user;
}
// ✅ Solve with JOIN or populate
const orders = await Order.findAll({
include: [{ model: User, attributes: ['name', 'email'] }]
}); // 1 query (or 2 with separate SELECT)
// ✅ Or batch load with DataLoader
const DataLoader = require('dataloader');
const userLoader = new DataLoader(async (ids) => {
const users = await User.findAll({ where: { id: ids } });
return ids.map(id => users.find(u => u.id === id));
});
// Now batches automatically
const user = await userLoader.load(order.userId); // Batched!
Database Connection Pooling
// PostgreSQL with pg
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Max connections in pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// All queries share the pool (no reconnect overhead)
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
HTTP Response Optimization
Compression
import compression from 'compression';
// Compress responses > 1kb
app.use(compression({
level: 6, // 1-9, higher = more compression but more CPU
threshold: 1024, // Only compress if > 1kb
filter: (req, res) => {
// Don't compress SSE streams
if (req.headers['accept'] === 'text/event-stream') return false;
return compression.filter(req, res);
}
}));
Streaming Large Responses
// ❌ Loads entire result into memory
app.get('/export', async (req, res) => {
const allUsers = await User.findAll(); // Could be millions!
res.json(allUsers);
});
// ✅ Stream the response
app.get('/export', async (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.write('[');
let first = true;
const stream = User.findAllStream(); // Cursor-based streaming
for await (const user of stream) {
if (!first) res.write(',');
res.write(JSON.stringify(user));
first = false;
}
res.write(']');
res.end();
});
Memory Leak Detection
// Monitor memory usage
setInterval(() => {
const used = process.memoryUsage();
console.log({
rss: Math.round(used.rss / 1024 / 1024) + 'MB',
heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB',
heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB',
external: Math.round(used.external / 1024 / 1024) + 'MB',
});
}, 30000);
Common Memory Leak Causes
// ❌ Leak: Event listeners not removed
class Server {
constructor() {
process.on('message', this.handleMessage.bind(this));
// This reference is never cleaned up!
}
}
// ✅ Track and remove listeners
class Server {
start() {
this.messageHandler = this.handleMessage.bind(this);
process.on('message', this.messageHandler);
}
stop() {
process.off('message', this.messageHandler);
}
}
// ❌ Leak: Growing cache without eviction
const cache = new Map();
function cacheData(key, value) {
cache.set(key, value); // Grows forever!
}
// ✅ Use LRU cache with max size
import LRU from 'lru-cache';
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });
Performance Checklist
- No synchronous file operations in request handlers (
fs.readFileSync→fs.promises.readFile) - No blocking loops in request handlers (move to worker threads)
- Database queries use indexes (run
EXPLAIN ANALYZEon slow queries) - N+1 queries eliminated (use JOINs or DataLoader)
- Connection pooling configured for databases
- Responses compressed with gzip/brotli
- Redis caching on expensive, frequently-read endpoints
- Cluster mode / PM2 for multi-core utilization
- Memory usage monitored, no unbounded growth
→ Benchmark your tools and algorithms with the Benchmark Builder.