Performance
Node.js is fast for I/O-bound workloads because its event loop can multiplex thousands of concurrent connections on a single thread. That same strength becomes a bottleneck when synchronous work blocks the loop, when memory leaks grow without bound, or when CPU-bound tasks monopolize the only available thread. Performance tuning in Node.js is therefore about protecting the event loop, scaling horizontally across CPU cores, and measuring before optimizing.
This guide covers the tools and patterns that matter in production: the cluster module for multi-process scaling, worker_threads for true parallelism, event-loop lag measurement, CPU and heap profiling, memory-leak detection, stream backpressure, caching strategies, benchmarking discipline, and load-balancing practices.
All examples target Node.js 20+ unless noted otherwise.
Premature optimization is especially dangerous in Node.js because the event loop makes some code unexpectedly fast and other code unexpectedly slow. A synchronous 5 ms JSON parse feels instant in development but destroys throughput at 1,000 requests per second. Always establish a baseline, identify the actual bottleneck, and then optimize.
| Metric | What It Tells You | Tool / API |
|---|---|---|
| Throughput | Requests or operations per second | autocannon, wrk, siege |
| Latency | Time to respond (p50, p95, p99) | autocannon --latency |
| Event loop lag | How long timers wait past their deadline | perf_hooks, histogram |
| CPU usage | Where time is spent on-CPU | --prof, clinic doctor |
| Memory growth | Leaks or excessive allocation | heap snapshots, --heapsnapshot-near-heap-limit |
A simple benchmark harness in pure Node.js can rule out obvious regressions before you introduce a full framework. Use performance.now() from node:perf_hooks for sub-millisecond precision and run enough iterations to amortize startup noise.
| 1 | import { performance } from 'node:perf_hooks'; |
| 2 | |
| 3 | function benchmark(name, fn, iterations = 1_000_000) { |
| 4 | for (let i = 0; i < 1000; i++) fn(); // warmup |
| 5 | const start = performance.now(); |
| 6 | for (let i = 0; i < iterations; i++) fn(); |
| 7 | const elapsed = performance.now() - start; |
| 8 | console.log(`${name}: ${(elapsed / iterations).toFixed(4)} ms/op`); |
| 9 | } |
| 10 | |
| 11 | benchmark('array push', () => { |
| 12 | const arr = []; |
| 13 | arr.push(1); |
| 14 | return arr; |
| 15 | }); |
warning
By default a Node.js process uses one CPU core. The node:cluster module forks worker processes that share the same server port, letting the operating system distribute incoming connections across available cores. This is the simplest way to scale a stateless HTTP server vertically.
| 1 | import cluster from 'node:cluster'; |
| 2 | import http from 'node:http'; |
| 3 | import os from 'node:os'; |
| 4 | |
| 5 | const numCPUs = os.availableParallelism(); |
| 6 | |
| 7 | if (cluster.isPrimary) { |
| 8 | console.log(`Primary ${process.pid} spawning ${numCPUs} workers`); |
| 9 | for (let i = 0; i < numCPUs; i++) cluster.fork(); |
| 10 | |
| 11 | cluster.on('exit', (worker, code, signal) => { |
| 12 | console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`); |
| 13 | cluster.fork(); |
| 14 | }); |
| 15 | } else { |
| 16 | const server = http.createServer((req, res) => { |
| 17 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 18 | res.end(JSON.stringify({ pid: process.pid })); |
| 19 | }); |
| 20 | server.listen(3000); |
| 21 | } |
Each worker is a separate process with its own memory space and event loop. Workers cannot share in-memory state directly, so use Redis or a database for shared data. Cluster load balancing is connection-based, so long-running WebSocket or keep-alive sessions can skew distribution; use a reverse proxy for request-level balancing.
best practice
The node:worker_threads module lets you run JavaScript in parallel threads within the same process. Unlike child processes, workers share memory through SharedArrayBuffer and Atomics, and communication via MessagePort is much faster than IPC.
| 1 | import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; |
| 2 | |
| 3 | if (isMainThread) { |
| 4 | const worker = new Worker(import.meta.filename, { workerData: { n: 40 } }); |
| 5 | worker.on('message', (result) => console.log(`Fibonacci result: ${result}`)); |
| 6 | worker.on('error', console.error); |
| 7 | worker.on('exit', (code) => { |
| 8 | if (code !== 0) console.error(`Worker stopped with exit code ${code}`); |
| 9 | }); |
| 10 | } else { |
| 11 | function fib(n) { |
| 12 | return n < 2 ? n : fib(n - 1) + fib(n - 2); |
| 13 | } |
| 14 | parentPort?.postMessage(fib(workerData.n)); |
| 15 | } |
Use worker threads for CPU-intensive work such as image resizing, PDF generation, and cryptographic key derivation. Do not use them for I/O-bound work; the event loop already handles that more efficiently. Creating a worker has overhead, so maintain a pool and reuse workers for sustained workloads.
| 1 | import { Worker, isMainThread, workerData } from 'node:worker_threads'; |
| 2 | |
| 3 | const buffer = new SharedArrayBuffer(4); |
| 4 | const counter = new Int32Array(buffer); |
| 5 | |
| 6 | if (isMainThread) { |
| 7 | for (let i = 0; i < 4; i++) { |
| 8 | new Worker(import.meta.filename, { workerData: buffer }); |
| 9 | } |
| 10 | } else { |
| 11 | const view = new Int32Array(workerData); |
| 12 | for (let i = 0; i < 100_000; i++) { |
| 13 | Atomics.add(view, 0, 1); // lock-free atomic increment |
| 14 | } |
| 15 | console.log(`Worker ${process.pid} done`); |
| 16 | } |
info
Event loop lag measures how late the loop is relative to when a timer was supposed to fire. Sustained lag above 50 ms degrades latency-sensitive workloads. Lag is usually caused by synchronous work: large JSON parsing, synchronous file reads, unbounded loops, or blocking cryptography.
| 1 | import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; |
| 2 | |
| 3 | const histogram = monitorEventLoopDelay({ resolution: 10 }); |
| 4 | histogram.enable(); |
| 5 | |
| 6 | setInterval(() => { |
| 7 | console.log({ |
| 8 | min: histogram.min, |
| 9 | max: histogram.max, |
| 10 | mean: histogram.mean, |
| 11 | p50: histogram.percentile(50), |
| 12 | p95: histogram.percentile(95), |
| 13 | p99: histogram.percentile(99), |
| 14 | }); |
| 15 | histogram.reset(); |
| 16 | }, 5000); |
| 17 | |
| 18 | // Node.js 20+ event loop utilization |
| 19 | const elu = performance.eventLoopUtilization(); |
| 20 | setInterval(() => { |
| 21 | const current = performance.eventLoopUtilization(elu); |
| 22 | console.log(`ELU: ${(current.utilization * 100).toFixed(2)}%`); |
| 23 | }, 5000); |
best practice
When latency is high but you cannot pinpoint the cause, profiling shows where the process spends time and memory. Node.js ships with built-in sampling profilers and an inspector protocol that integrates with Chrome DevTools.
| 1 | # V8 tick profile |
| 2 | node --prof server.js |
| 3 | node --prof-process isolate-0x*-v8.log > profile.txt |
| 4 | |
| 5 | # Inspector protocol — open chrome://inspect |
| 6 | node --inspect server.js |
| 7 | node --inspect-brk server.js # break on first line |
The --prof output lists the hottest JavaScript and C++ functions. Look for functions with high [self] and [total] tick counts. Native stack frames from V8 builtins, libuv, and crypto often reveal hidden costs such as excessive stringification or JSON parsing.
| 1 | # Clinic.js diagnoses common performance issues |
| 2 | npx clinic doctor -- node server.js |
| 3 | npx clinic bubbleprof -- node server.js |
| 4 | npx clinic flame -- node server.js |
info
Heap snapshots capture the object graph at a point in time. Compare two snapshots taken before and after a suspected leak to see which object types grew. Generate them programmatically or via the inspector.
| 1 | import v8 from 'node:v8'; |
| 2 | |
| 3 | function takeHeapSnapshot(label) { |
| 4 | const snapshot = v8.writeHeapSnapshot(`heap-${label}-${Date.now()}.heapsnapshot`); |
| 5 | console.log(`Snapshot written: ${snapshot}`); |
| 6 | } |
| 7 | |
| 8 | setInterval(() => takeHeapSnapshot('baseline'), 60_000); |
| 9 | |
| 10 | // Auto-snapshot before OOM crash |
| 11 | // node --heapsnapshot-near-heap-limit=3 server.js |
Node.js memory usage grows until the heap limit is reached, then the process exits with an out-of-memory error. The most common leaks are accidental closures, unbounded caches, forgotten event listeners, and timers that hold references to large objects.
| Leak Source | Symptom | Mitigation |
|---|---|---|
| Event listeners | Listeners accumulate per request | Use once(), removeListener(), or AbortSignal |
| Unbounded caches | Map grows without eviction | LRU cache with TTL and max size |
| Closures | Large objects retained by callbacks | Scope variables narrowly, null references |
| Timers | setInterval keeps objects alive | clearInterval when component/request ends |
| Native handles | Streams, sockets, database connections | Close explicitly, use connection pooling |
| 1 | // LEAK: listeners grow with every request |
| 2 | server.on('request', (req, res) => { |
| 3 | const huge = Buffer.alloc(10_000_000); |
| 4 | req.on('data', () => {}); |
| 5 | req.on('end', () => res.end(huge)); |
| 6 | }); |
| 7 | |
| 8 | // BETTER: scope data to the request and remove listeners |
| 9 | server.on('request', (req, res) => { |
| 10 | const chunks = []; |
| 11 | req.on('data', (chunk) => chunks.push(chunk)); |
| 12 | req.once('end', () => res.end(Buffer.concat(chunks))); |
| 13 | }); |
For caches that should not keep objects alive forever, prefer WeakMap or WeakRef when the lifetime of the cached value should follow the lifetime of an external object. For request-scoped caches, use an LRU with explicit bounds.
| 1 | // Simple bounded LRU using Map order semantics |
| 2 | class LRUCache { |
| 3 | constructor(maxSize) { |
| 4 | this.maxSize = maxSize; |
| 5 | this.cache = new Map(); |
| 6 | } |
| 7 | |
| 8 | get(key) { |
| 9 | if (!this.cache.has(key)) return undefined; |
| 10 | const value = this.cache.get(key); |
| 11 | this.cache.delete(key); |
| 12 | this.cache.set(key, value); |
| 13 | return value; |
| 14 | } |
| 15 | |
| 16 | set(key, value) { |
| 17 | if (this.cache.has(key)) this.cache.delete(key); |
| 18 | else if (this.cache.size >= this.maxSize) { |
| 19 | const oldest = this.cache.keys().next().value; |
| 20 | this.cache.delete(oldest); |
| 21 | } |
| 22 | this.cache.set(key, value); |
| 23 | } |
| 24 | } |
warning
Streams let you move data incrementally without loading everything into memory. Backpressure happens when a fast producer overwhelms a slow consumer. Ignoring backpressure causes memory spikes, latency jumps, and eventually process crashes.
| 1 | // WRONG: reads entire file into memory |
| 2 | import fs from 'node:fs'; |
| 3 | app.get('/file', (req, res) => { |
| 4 | const data = fs.readFileSync('./large.zip'); |
| 5 | res.send(data); |
| 6 | }); |
| 7 | |
| 8 | // RIGHT: stream with backpressure handled |
| 9 | import { pipeline } from 'node:stream/promises'; |
| 10 | app.get('/file', (req, res) => { |
| 11 | pipeline(fs.createReadStream('./large.zip'), res).catch((err) => { |
| 12 | if (!res.headersSent) res.status(500).end(err.message); |
| 13 | }); |
| 14 | }); |
The pipeline function from node:stream/promises automatically handles errors and closes streams. Always prefer it over .pipe(), which leaks error handlers and does not propagate cleanup. Async iterator streams created with Readable.from() are backpressure-aware by default and work well with pipeline.
best practice
Caching is the highest-impact performance improvement for read-heavy workloads, but it introduces complexity around invalidation, consistency, and stale data. A good cache has a clear TTL, bounded size, and a defined invalidation policy.
| Strategy | When to Use | Trade-off |
|---|---|---|
| In-process LRU | Low-latency, single-node data | Not shared across instances |
| Redis | Shared state, distributed cache | Network overhead, another dependency |
| CDN | Static assets, idempotent GETs | Invalidation latency, cache keys |
| Memoization | Pure functions with repeated inputs | Memory growth if input space is large |
| 1 | import { createClient } from 'redis'; |
| 2 | |
| 3 | const redis = createClient({ url: process.env.REDIS_URL }); |
| 4 | await redis.connect(); |
| 5 | |
| 6 | async function getCached(key, fetchFn, ttlSeconds = 60) { |
| 7 | const cached = await redis.get(key); |
| 8 | if (cached) return JSON.parse(cached); |
| 9 | |
| 10 | const value = await fetchFn(); |
| 11 | await redis.setEx(key, ttlSeconds, JSON.stringify(value)); |
| 12 | return value; |
| 13 | } |
| 14 | |
| 15 | // Cache-aside pattern |
| 16 | app.get('/users/:id', async (req, res) => { |
| 17 | const user = await getCached(`user:${req.params.id}`, async () => { |
| 18 | return db.user.findById(req.params.id); |
| 19 | }, 300); |
| 20 | res.json(user); |
| 21 | }); |
note
Vertical scaling with cluster and worker threads takes you only as far as one machine. Horizontal scaling across multiple machines requires a load balancer and a stateless application design. Statelessness means any request can be handled by any instance without depending on local memory.
| 1 | upstream node_app { |
| 2 | least_conn; |
| 3 | server 127.0.0.1:3000; |
| 4 | server 127.0.0.1:3001; |
| 5 | server 127.0.0.1:3002; |
| 6 | server 127.0.0.1:3003; |
| 7 | } |
| 8 | |
| 9 | server { |
| 10 | listen 80; |
| 11 | location / { |
| 12 | proxy_pass http://node_app; |
| 13 | proxy_http_version 1.1; |
| 14 | proxy_set_header Connection ""; |
| 15 | proxy_set_header Host $host; |
| 16 | proxy_set_header X-Real-IP $remote_addr; |
| 17 | } |
| 18 | } |
Health checks route traffic away from failed instances. A minimal health endpoint should verify that the event loop is responsive and that critical dependencies such as the database or cache are reachable. Do not make health checks expensive.
| 1 | import http from 'node:http'; |
| 2 | |
| 3 | let lastLoopLagMs = 0; |
| 4 | setInterval(() => { |
| 5 | const start = performance.now(); |
| 6 | setImmediate(() => { |
| 7 | lastLoopLagMs = performance.now() - start; |
| 8 | }); |
| 9 | }, 1000); |
| 10 | |
| 11 | const server = http.createServer((req, res) => { |
| 12 | if (req.url === '/health') { |
| 13 | const healthy = lastLoopLagMs < 100; |
| 14 | res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' }); |
| 15 | res.end(JSON.stringify({ status: healthy ? 'ok' : 'degraded', lagMs: lastLoopLagMs })); |
| 16 | return; |
| 17 | } |
| 18 | // ... application routes |
| 19 | }); |
best practice
Most Node.js performance problems are repeated patterns that block the event loop, waste memory, or serialize work unnecessarily. The worst offenders are synchronous file system calls, large JSON.parse operations, unpooled database connections, synchronous logging, CPU work on the main thread, and missing request timeouts.
warning