Node.js Performance and Profiling: Event Loop, Worker Threads, Memory Leaks and Clustering
Node.js can handle massive concurrency on a single thread β but only if you understand how the event loop works and avoid the patterns that kill performance. This guide covers the internals that matter for production systems and the tools to diagnose problems when they occur.
The Event Loop in Detail
Node.js runs JavaScript on a single thread using an event loop. The event loop processes phases in order:
codeββββββββββββββββββββββββββββββββ βββΊβ timers β -- setTimeout, setInterval callbacks β ββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββ β β pending callbacks β -- I/O errors from previous iteration β ββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββ β β poll β -- fetch new I/O events, execute callbacks β ββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββ β β check β -- setImmediate callbacks β ββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββ ββββ close callbacks β -- socket.on("close", ...) ββββββββββββββββββββββββββββββββ
Between each phase, Node.js drains the microtask queue: resolved Promises and queueMicrotask() callbacks. Microtasks always run before the next event loop phase.
javascriptconsole.log("1 - sync"); setTimeout(() => console.log("4 - setTimeout"), 0); setImmediate(() => console.log("5 - setImmediate")); Promise.resolve().then(() => console.log("3 - microtask")); console.log("2 - sync"); -- Output: 1, 2, 3, 4, 5 -- Sync code first, then microtasks, then timers/immediate
Blocking the Event Loop
The single most important performance rule: never block the event loop with synchronous CPU-bound work.
javascript-- Bad: blocks the event loop for all incoming requests app.get("/hash", (req, res) => { const hash = crypto.pbkdf2Sync( req.body.password, "salt", 100000, 64, "sha512" ); -- synchronous, takes ~200ms -- all other requests wait! res.json({ hash: hash.toString("hex") }); }); -- Bad: parsing a huge JSON string blocks the loop app.post("/data", (req, res) => { const data = JSON.parse(hugeJsonString); -- can take seconds res.json({ count: data.length }); }); -- Bad: synchronous file read in a request handler app.get("/report", (req, res) => { const file = fs.readFileSync("/data/large-report.csv"); -- blocks res.send(process(file)); });
Signs that you are blocking the event loop:
- High event loop lag (measured with
perf_hooks) - All requests slow down simultaneously when one request is processing
- CPU at 100% on a single core while other cores are idle
Worker Threads for CPU-Bound Work
Use Worker Threads to offload CPU-intensive work to separate threads:
javascriptimport { Worker, isMainThread, parentPort, workerData } from "worker_threads"; import { cpus } from "os"; -- worker.js if (!isMainThread) { const { data } = workerData; -- CPU-intensive operation runs in a separate thread const result = heavyComputation(data); parentPort.postMessage(result); } -- main.js function runWorker(data) { return new Promise((resolve, reject) => { const worker = new Worker(new URL(import.meta.url), { 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}`)); }); }); } -- Worker pool for reusing workers import { StaticPool } from "node-worker-threads-pool"; const pool = new StaticPool({ size: cpus().length, task: "./worker.js", }); app.post("/compute", async (req, res) => { const result = await pool.exec(req.body.data); res.json({ result }); });
Worker threads share memory via SharedArrayBuffer and Atomics for high-performance communication.
Clustering for Multi-Core Utilization
Node.js runs on one CPU core by default. Clustering spawns one process per core:
javascriptimport cluster from "cluster"; import { cpus } from "os"; import http from "http"; if (cluster.isPrimary) { const numCPUs = cpus().length; console.log(`Primary ${process.pid} starting ${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 share the same port const app = createExpressApp(); app.listen(3000, () => { console.log(`Worker ${process.pid} started`); }); }
In production, use PM2 instead of rolling your own cluster management:
bashpm2 start app.js -i max -- one instance per CPU core pm2 start app.js -i 4 -- exactly 4 instances pm2 monit -- live monitoring dashboard pm2 logs -- tail all logs
Detecting Memory Leaks
Common causes of memory leaks in Node.js:
1. Accumulating event listeners
javascript-- Leak: adding listeners inside a loop or repeated function function setupHandler() { emitter.on("data", processData); -- adds a new listener every call } -- Fix: use once() for one-time listeners, or removeListener() when done emitter.once("data", processData); -- or const handler = (data) => processData(data); emitter.on("data", handler); -- later... emitter.removeListener("data", handler);
2. Closures holding large objects
javascript-- Leak: large array captured in a closure that lives forever function createLeak() { const largeArray = new Array(1000000).fill("data"); return { process: () => largeArray.length, -- keeps largeArray alive forever }; } const leaked = createLeak(); -- largeArray is never GC'd as long as leaked exists
3. Forgotten timers and intervals
javascript-- Leak: interval holds a reference to callback scope const cache = new Map(); setInterval(() => { cache.set(Date.now(), fetchData()); -- grows forever }, 1000); -- Fix: clear intervals when done const interval = setInterval(() => { ... }, 1000); -- When component unmounts or server shuts down: clearInterval(interval);
Detecting leaks with --inspect
bash-- Start Node.js with inspector node --inspect app.js -- In Chrome: chrome://inspect -- Take heap snapshots before and after suspected leak -- Compare: look for retained objects growing between snapshots
javascript-- Programmatic heap snapshot import v8 from "v8"; import fs from "fs"; function takeHeapSnapshot() { const snapshotStream = v8.writeHeapSnapshot(); console.log("Heap snapshot written to:", snapshotStream); } -- Take snapshots periodically in development to compare
Measuring Event Loop Lag
javascriptimport { monitorEventLoopDelay } from "perf_hooks"; -- Histogram-based event loop monitoring const histogram = monitorEventLoopDelay({ resolution: 10 }); histogram.enable(); setInterval(() => { const lagMs = histogram.mean / 1e6; -- nanoseconds to milliseconds const p99Ms = histogram.percentile(99) / 1e6; if (lagMs > 50) { console.warn(`High event loop lag: mean=${lagMs.toFixed(2)}ms p99=${p99Ms.toFixed(2)}ms`); } histogram.reset(); }, 5000);
Target: mean event loop lag under 10ms. Spikes above 100ms indicate blocking code.
CPU Profiling
bash-- Built-in profiler node --prof app.js -- Run a load test, then Ctrl+C node --prof-process isolate-*.log > profile.txt -- Read profile.txt to see where time is spent
javascript-- Programmatic profiling with v8-profiler-next import inspector from "inspector"; import fs from "fs"; const session = new inspector.Session(); session.connect(); -- Start CPU profile session.post("Profiler.enable", () => { session.post("Profiler.start", () => { setTimeout(() => { -- Stop and save profile after 10 seconds session.post("Profiler.stop", (err, param) => { fs.writeFileSync("profile.cpuprofile", JSON.stringify(param.profile)); -- Open in Chrome DevTools: More Tools -> JavaScript Profiler -> Load }); }, 10000); }); });
Common Interview Questions
Q: Why does Node.js use a single thread and how does it achieve high concurrency?
Node.js uses a single JavaScript thread but delegates I/O operations (file reads, network calls, database queries) to libuv's thread pool and the OS's async I/O. When an I/O operation completes, its callback is queued for the event loop to execute. The JavaScript thread is never blocked waiting β it handles callbacks as they arrive. This works well for I/O-bound workloads but poorly for CPU-bound work, which blocks the single thread.
Q: What is the difference between setImmediate and setTimeout(fn, 0)?
Both schedule a callback "soon," but in different phases of the event loop. setImmediate runs in the check phase, after the current poll phase completes. setTimeout(fn, 0) runs in the timers phase on the next iteration. Within an I/O callback, setImmediate always fires before setTimeout. Outside an I/O callback, the order is not guaranteed.
Q: When would you use Worker Threads vs Clustering?
Worker Threads share the same process and memory β use them for CPU-intensive tasks within a single request (image processing, cryptography, parsing). Clustering forks separate processes β use it to utilize all CPU cores for handling more concurrent requests, since each process handles requests independently with full memory isolation.
Practice Node.js on Froquiz
Node.js internals and performance are tested in senior backend interviews. Test your Node.js knowledge on Froquiz across all difficulty levels.
Summary
- The event loop processes phases in order: timers β poll β check β close; microtasks run between phases
- Never run synchronous CPU-heavy work in request handlers β it blocks all concurrent requests
- Worker Threads offload CPU work to separate threads without blocking the event loop
- Clustering forks one process per CPU core β PM2 manages this automatically in production
- Memory leaks come from accumulated event listeners, closures holding large objects, and forgotten timers
--inspect+ Chrome DevTools heap snapshots identify retained objects growing over time- Monitor event loop lag β mean above 10ms signals blocking code somewhere