|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/performance
$cat docs/performance.md
updated Recently·20-28 min read·published

Performance

Node.jsPerformanceAdvanced🎯Free Tools
Introduction

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.

Measure Before Optimizing

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.

MetricWhat It Tells YouTool / API
ThroughputRequests or operations per secondautocannon, wrk, siege
LatencyTime to respond (p50, p95, p99)autocannon --latency
Event loop lagHow long timers wait past their deadlineperf_hooks, histogram
CPU usageWhere time is spent on-CPU--prof, clinic doctor
Memory growthLeaks or excessive allocationheap 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.

benchmark-harness.mjs
JavaScript
1import { performance } from 'node:perf_hooks';
2
3function 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
11benchmark('array push', () => {
12 const arr = [];
13 arr.push(1);
14 return arr;
15});

warning

Microbenchmarks are misleading. The V8 JIT optimizes aggressively, inline caches warm up, and garbage collection skews short runs. Benchmark the full request path under realistic load, and profile the resulting CPU and memory behavior.
Scaling with the Cluster Module

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.

cluster-server.mjs
JavaScript
1import cluster from 'node:cluster';
2import http from 'node:http';
3import os from 'node:os';
4
5const numCPUs = os.availableParallelism();
6
7if (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

Use os.availableParallelism() in Node.js 20+ instead of os.cpus().length to respect container CPU limits. Leave one core free for the operating system and other processes on small instances, or use a pool size controlled by an environment variable.
Parallelism with worker_threads

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.

worker-fib.mjs
JavaScript
1import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
2
3if (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.

sharedarraybuffer.mjs
JavaScript
1import { Worker, isMainThread, workerData } from 'node:worker_threads';
2
3const buffer = new SharedArrayBuffer(4);
4const counter = new Int32Array(buffer);
5
6if (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

When sharing memory, coordinate access with Atomics to prevent data races. Without atomics, two threads can read and write the same index simultaneously and corrupt state. For one-off CPU tasks, weigh serialization and thread startup against running the task on the main thread.
Event Loop Lag and Utilization

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.

event-loop-metrics.mjs
JavaScript
1import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
2
3const histogram = monitorEventLoopDelay({ resolution: 10 });
4histogram.enable();
5
6setInterval(() => {
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
19const elu = performance.eventLoopUtilization();
20setInterval(() => {
21 const current = performance.eventLoopUtilization(elu);
22 console.log(`ELU: ${(current.utilization * 100).toFixed(2)}%`);
23}, 5000);

best practice

Export p99 event-loop lag and utilization as Prometheus or StatsD metrics. Alert on sustained p99 lag above your SLO, and scale out when utilization stays above 70-80%. Average lag hides tail latency spikes that directly impact user experience.
CPU and Heap Profiling

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.

profiling-commands.sh
Bash
1# V8 tick profile
2node --prof server.js
3node --prof-process isolate-0x*-v8.log > profile.txt
4
5# Inspector protocol — open chrome://inspect
6node --inspect server.js
7node --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.

clinic-js.sh
Bash
1# Clinic.js diagnoses common performance issues
2npx clinic doctor -- node server.js
3npx clinic bubbleprof -- node server.js
4npx clinic flame -- node server.js

info

Run profiling under realistic load, not an idle process. Use a load generator like autocannon while the profiler is active so the sample reflects production behavior. Profiling an idle server produces meaningless noise.

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.

heap-snapshot.mjs
JavaScript
1import v8 from 'node:v8';
2
3function takeHeapSnapshot(label) {
4 const snapshot = v8.writeHeapSnapshot(`heap-${label}-${Date.now()}.heapsnapshot`);
5 console.log(`Snapshot written: ${snapshot}`);
6}
7
8setInterval(() => takeHeapSnapshot('baseline'), 60_000);
9
10// Auto-snapshot before OOM crash
11// node --heapsnapshot-near-heap-limit=3 server.js
Memory Leaks and Retention

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 SourceSymptomMitigation
Event listenersListeners accumulate per requestUse once(), removeListener(), or AbortSignal
Unbounded cachesMap grows without evictionLRU cache with TTL and max size
ClosuresLarge objects retained by callbacksScope variables narrowly, null references
TimerssetInterval keeps objects aliveclearInterval when component/request ends
Native handlesStreams, sockets, database connectionsClose explicitly, use connection pooling
listener-leak.mjs
JavaScript
1// LEAK: listeners grow with every request
2server.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
9server.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.

lru-cache.mjs
JavaScript
1// Simple bounded LRU using Map order semantics
2class 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

Do not rely solely on garbage collection for resource cleanup. Database connections, file descriptors, streams, and timers must be closed or cleared explicitly. Use AbortController to propagate cancellation and clean up resources in modern Node.js code.
Streams and Backpressure

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.

streams-backpressure.mjs
JavaScript
1// WRONG: reads entire file into memory
2import fs from 'node:fs';
3app.get('/file', (req, res) => {
4 const data = fs.readFileSync('./large.zip');
5 res.send(data);
6});
7
8// RIGHT: stream with backpressure handled
9import { pipeline } from 'node:stream/promises';
10app.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

Tune highWaterMark based on your workload. The default is 16 KB for streams and 16 objects for object mode. Larger buffers improve throughput for fast producers; smaller buffers reduce memory for bursty or slow consumers.
Caching Strategies

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.

StrategyWhen to UseTrade-off
In-process LRULow-latency, single-node dataNot shared across instances
RedisShared state, distributed cacheNetwork overhead, another dependency
CDNStatic assets, idempotent GETsInvalidation latency, cache keys
MemoizationPure functions with repeated inputsMemory growth if input space is large
redis-cache.mjs
JavaScript
1import { createClient } from 'redis';
2
3const redis = createClient({ url: process.env.REDIS_URL });
4await redis.connect();
5
6async 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
16app.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

Use a cache stampede mitigation strategy such as per-key jitter, lease-based single-flight, or Redis Redlock for hot keys. Without it, a popular cache expiry can trigger a thundering herd against your database. For deterministic pure functions, memoize with a bounded LRU to avoid repeated computation.
Load Balancing and Stateless Design

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.

nginx-upstream.conf
NGINX
1upstream 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
9server {
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.

health-check.mjs
JavaScript
1import http from 'node:http';
2
3let lastLoopLagMs = 0;
4setInterval(() => {
5 const start = performance.now();
6 setImmediate(() => {
7 lastLoopLagMs = performance.now() - start;
8 });
9}, 1000);
10
11const 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

Keep user sessions in Redis or a signed cookie, not in process memory. Uploads and WebSocket state also require external storage if users may reconnect to a different instance. A stateless design is what makes horizontal scaling possible.
Common Performance Mistakes

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

Never increase timeouts to fix slowness. Longer timeouts hide backpressure and resource leaks. Fix the root cause, then set server.keepAliveTimeout, headersTimeout, requestTimeout, and server.timeout just above your expected p99 latency.
$Blueprint — Engineering Documentation·Section ID: NODE-PERF-01·Revision: 1.0