Node.js Performance Starts With Understanding the Event Loop
Node.js runs your JavaScript on a single thread. That thread manages the event loop — a cycle that processes callbacks, I/O events, timers, and microtasks. When people say Node.js is "single-threaded," they mean your application code runs on one thread. I/O operations and some built-in functions use a thread pool (libuv's pool, 4 threads by default).
The critical insight: anything that blocks the event loop blocks everything. While your code is crunching a 10MB JSON parse, no HTTP requests are being handled, no database callbacks are firing, nothing happens. The event loop is stuck.
Diagnosing Event Loop Blocking
The --inspect flag enables the Chrome DevTools protocol. Connect Chrome to chrome://inspect and you can profile your Node.js process just like a browser tab — CPU profiles, heap snapshots, and the performance timeline.
For production monitoring without the overhead of a debugger, use event loop lag measurement:
const start = process.hrtime.bigint();
setImmediate(() => {
const lag = Number(process.hrtime.bigint() - start) / 1_000_000;
if (lag > 100) {
console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
}
});
// Or use the built-in perf_hooks
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log(`Event loop p99: ${(h.percentile(99) / 1e6).toFixed(1)}ms`);
h.reset();
}, 5000);
Healthy event loop lag should be under 10ms. If p99 is above 50ms, you've got blocking operations that need to be moved off the main thread.
Worker Threads for CPU-Bound Work
Node.js has had Worker Threads since v12. They're true OS threads with their own V8 isolate — they don't share memory with the main thread (except SharedArrayBuffer) and they don't block the event loop.
// worker.js
const { parentPort, workerData } = require('worker_threads');
function heavyComputation(data) {
// CPU-intensive work: JSON parsing, encryption, image processing, etc.
let result = 0;
for (let i = 0; i < data.iterations; i++) {
result += Math.sqrt(i) * Math.sin(i);
}
return result;
}
parentPort.postMessage(heavyComputation(workerData));
// main.js
const { Worker } = require('worker_threads');
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
});
}
// Non-blocking: event loop continues while worker crunches numbers
app.get('/compute', async (req, res) => {
const result = await runWorker({ iterations: 10_000_000 });
res.json({ result });
});
Creating a Worker has overhead (~30-50ms startup). For frequent tasks, use a worker pool like piscina or workerpool that keeps threads alive between tasks:
const Piscina = require('piscina');
const pool = new Piscina({
filename: './worker.js',
minThreads: 2,
maxThreads: 4,
});
app.get('/compute', async (req, res) => {
const result = await pool.run({ iterations: 10_000_000 });
res.json({ result });
});
Memory Profiling and Leak Detection
V8 has a generational garbage collector. New objects go in "young generation" (small, collected frequently). Long-lived objects get promoted to "old generation" (larger, collected less often). Full GC pauses — where V8 scans the entire old generation — are the ones that cause visible latency spikes.
To take a heap snapshot in production:
const v8 = require('v8');
const fs = require('fs');
function dumpHeap() {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const snapshotStream = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${filename}`);
return filename;
}
// Trigger via HTTP endpoint (protect this in production!)
app.post('/debug/heap-dump', (req, res) => {
const file = dumpHeap();
res.json({ file });
});
Load the .heapsnapshot file in Chrome DevTools (Memory tab) to analyze object retention. Look for:
- Growing arrays or Maps — often event listeners that aren't being removed, or caches without eviction
- Detached DOM trees — in server-side rendering scenarios (Next.js, etc.)
- Closures holding references — callbacks that capture large objects in their closure scope
Common Memory Leak Patterns
// LEAK: global array that grows forever
const requestLog = [];
app.use((req, res, next) => {
requestLog.push({ url: req.url, time: Date.now() });
next();
});
// LEAK: event listeners never removed
class Connection {
constructor(socket) {
socket.on('data', this.handleData.bind(this));
// If Connection objects are created/destroyed frequently,
// the listener stays on the socket
}
}
Monitor process.memoryUsage() over time. If heapUsed trends upward without plateauing, you've got a leak. The --max-old-space-size flag (default ~1.5GB on 64-bit) sets the heap limit — Node.js crashes with an OOM error when it's exceeded.
Stream Processing for Large Data
Buffering an entire file or HTTP response in memory defeats the purpose of Node.js's event-driven architecture. Streams process data in chunks:
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { createGzip } = require('zlib');
// Process a 2GB file with ~64KB of memory
await pipeline(
createReadStream('input.csv'),
createGzip(),
createWriteStream('output.csv.gz')
);
For HTTP responses, pipe database query results directly to the response instead of loading everything into memory:
app.get('/export', (req, res) => {
res.setHeader('Content-Type', 'text/csv');
const cursor = db.collection('users').find().stream();
cursor.pipe(new CSVTransform()).pipe(res);
});
Quick Wins for Existing Applications
- Increase UV_THREADPOOL_SIZE if you do heavy file I/O or DNS lookups:
UV_THREADPOOL_SIZE=16 node server.js(default is 4) - Use
JSON.parsestreaming for large JSON payloads — thestream-jsonpackage processes JSON token-by-token instead of buffering the entire string - Enable HTTP keep-alive on outbound requests:
new http.Agent({ keepAlive: true })reuses TCP connections instead of opening a new one per request - Set
--max-semi-space-size=64for high-allocation-rate services (many short-lived objects). Default young generation is 16MB; increasing it reduces GC frequency at the cost of slightly longer individual pauses. - Profile before optimizing.
clinic.js(from NearForm) generates flame graphs, event loop analysis, and I/O recommendations automatically.