Performance & perf_hooks
Why measure?
Section titled “Why measure?”“Premature optimization is the root of all evil.” — Donald Knuth
Before you optimize anything, measure first. Node’s perf_hooks module gives you sub-millisecond timing via the High Resolution Time API — the same performance.now() you know from browsers, but available natively in Node without any import in newer versions (global since Node 16), and as perf_hooks for PerformanceObserver.
performance.now()
Section titled “performance.now()”performance.now() returns a float in milliseconds with microsecond precision. It is monotonic — it never goes backwards, even across system clock adjustments:
import { performance } from 'node:perf_hooks';
const start = performance.now();
// --- work to measure ---let sum = 0;for (let i = 0; i < 1_000_000; i++) { sum += i;}// ----------------------
const elapsed = performance.now() - start;console.log('Sum loop took: ' + elapsed.toFixed(3) + ' ms');performance.mark() and performance.measure()
Section titled “performance.mark() and performance.measure()”For named measurements that show up in DevTools timelines:
import { performance, PerformanceObserver } from 'node:perf_hooks';
performance.mark('db-start');// ... simulate a DB query ...await new Promise(function(resolve) { setTimeout(resolve, 50); });performance.mark('db-end');
performance.measure('database-query', 'db-start', 'db-end');
const entries = performance.getEntriesByName('database-query');console.log(entries[0].duration.toFixed(2) + ' ms');PerformanceObserver — async collection
Section titled “PerformanceObserver — async collection”PerformanceObserver fires a callback whenever a new performance entry is created. This is useful for instrumenting library code that you do not control:
import { PerformanceObserver, performance } from 'node:perf_hooks';
const obs = new PerformanceObserver(function(list) { for (const entry of list.getEntries()) { console.log(entry.name + ': ' + entry.duration.toFixed(2) + ' ms'); }});
obs.observe({ entryTypes: ['measure'] });
performance.mark('a');// ... work ...performance.mark('b');performance.measure('my-operation', 'a', 'b');
// Always disconnect when done to avoid memory leaksobs.disconnect();entryTypes is not limited to 'measure'. 'function' entries come from performance.timerify(), which wraps a function and emits a PerformanceEntry every time it is called — no manual mark/measure pair needed:
Output (verified on Node 24)
observed entry types include function: true'gc' entries need --expose-gc (to call global.gc() on demand) and observe automatically whenever a real garbage collection pass runs — useful for correlating a latency spike with a GC pause:
node --expose-gc gc-observer.mjsgc entry seen: true// @skip-verify requires --expose-gc, not passed by default hereimport { PerformanceObserver } from 'node:perf_hooks';
const obs = new PerformanceObserver((list) => { console.log('gc entry seen:', list.getEntries().length > 0); obs.disconnect();});obs.observe({ entryTypes: ['gc'] });global.gc();Measuring event loop lag with monitorEventLoopDelay
Section titled “Measuring event loop lag with monitorEventLoopDelay”performance.now() times work you already know about. monitorEventLoopDelay() answers a different question — “is anything blocking the loop right now, even code I don’t control?” — by sampling the gap between event loop iterations:
Output (verified on Node 24)
saw event loop delay: true
min <= mean <= max: trueh.min/h.mean/h.max/h.percentile(N) are all in nanoseconds. A production server exposing h.percentile(99) on a health/metrics endpoint tells you about lag caused by any synchronous work anywhere in the process — your code, a dependency, even native addons — not just the specific span you happened to wrap in performance.mark().
Live demo — timing a CPU-bound loop
Section titled “Live demo — timing a CPU-bound loop”Needs the Node.js runtime — open in StackBlitz to run.
Keeping the event loop unblocked
Section titled “Keeping the event loop unblocked”A CPU-bound loop that runs for more than ~50 ms will block the event loop — no I/O, no timers, no other requests can be processed while it runs.
// BAD — blocks the event loop for the entire durationfunction blockingWork(data) { const result = []; for (const item of data) { result.push(expensiveTransform(item)); // each call takes ~1ms, 10000 items = 10s block } return result;}
// BETTER — yield control periodically with setImmediateasync function nonBlockingWork(data) { const result = []; for (let i = 0; i < data.length; i++) { result.push(expensiveTransform(data[i])); if (i % 100 === 0) { // Yield to the event loop every 100 items await new Promise(function(resolve) { setImmediate(resolve); }); } } return result;}
// BEST for CPU-heavy work — offload to a Worker Thread (see below in this lesson)import { Worker } from 'node:worker_threads';Profiling with --cpu-prof and --heap-prof
Section titled “Profiling with --cpu-prof and --heap-prof”The older --prof/--prof-process pair still works, but the modern, simpler flags write a file you can drag straight into Chrome DevTools’ Performance/Memory panel — no processing step:
node --cpu-prof --cpu-prof-dir=. --cpu-prof-name=demo.cpuprofile src/app.js(writes demo.cpuprofile to the current directory once the process exits)node --heap-prof --heap-prof-dir=. --heap-prof-name=demo.heapprofile src/app.js(writes demo.heapprofile to the current directory once the process exits)Open either file in Chrome DevTools (chrome://inspect → “Open dedicated DevTools for Node” → Profiler/Memory tab → Load) to get a flame graph or a heap allocation breakdown without leaving the browser you already use for everything else.
worker_threads for CPU-bound work
Section titled “worker_threads for CPU-bound work”setImmediate chunking (above) keeps the event loop responsive by interleaving the work with everything else — the total CPU time spent is the same, just spread out. A Worker actually moves the work to a different thread, so the main thread can keep handling I/O in parallel instead of taking turns with it:
Output (verified on Node 24)
worker result: 832040
main thread was free to do other work while this ranA single file works as both the main-thread entry point and the worker’s script by branching on isMainThread — new Worker(new URL(import.meta.url), ...) re-runs this same file in a new thread, and workerData/parentPort.postMessage() are the two-way channel in and out. For real workloads, put the worker in its own file once the logic grows past a few lines.
cluster vs. a reverse proxy
Section titled “cluster vs. a reverse proxy”Node’s built-in cluster module forks multiple copies of your process (one per CPU core, typically) that share listening ports, giving you multi-core usage without a separate load balancer:
Output (verified on Node 24)
primary pid, forking 2 workers
worker running, exiting immediately
worker running, exiting immediately
all workers exited, primary doneIn practice, most production deployments reach for a reverse proxy (nginx, a cloud load balancer, or a container orchestrator running N replicas) instead of cluster directly: a reverse proxy also handles TLS termination, health checks, zero-downtime rolling restarts, and horizontal scaling across machines, not just cores on one machine — cluster only helps you use the cores on the box you’re already on. cluster is still useful for a single-machine deployment or as the mechanism a process manager (like PM2) uses under the hood.
For load testing either setup, autocannon (npx autocannon http://localhost:3000) is the standard Node-ecosystem tool — it is not built in, but it is the de facto way to generate concurrent HTTP load and see requests/sec and latency percentiles against a running server.
Profiling loop
Section titled “Profiling loop”flowchart LR
Measure["Measure: performance.now() / monitorEventLoopDelay / --cpu-prof"] --> Identify["Identify the actual hot path"]
Identify --> Decide{"What kind of cost?"}
Decide -->|"blocks the loop, moderate"| Chunk["Chunk with setImmediate/await"]
Decide -->|"blocks the loop, heavy CPU"| Offload["Offload to worker_threads"]
Decide -->|"single-core limit under load"| Scale["cluster / reverse proxy replicas"]
Chunk --> Measure
Offload --> Measure
Scale --> Measure Mental Model
Section titled “Mental Model”Every technique on this page is really answering one of two questions: “how long did this take?” (performance.now, mark/measure, --cpu-prof) or “is something, anywhere, blocking the loop right now?” (monitorEventLoopDelay). Optimization is not something you do once — it’s this loop: measure, find the actual hot path (not the one you assumed), fix that one thing, measure again.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
setImmediate/await chunking | Simple, no new thread, keeps total work on one core | Same total CPU time, just interleaved — does not add throughput |
worker_threads | True parallelism, main thread stays responsive for I/O | Startup overhead per worker, no shared memory by default, more code to coordinate |
cluster (multi-process) | Uses every core on one machine, minimal code change | Each worker process has its own memory (no shared in-process cache), still one machine’s ceiling |
| Reverse proxy + N replicas | Scales across machines, handles TLS/health checks/rolling restarts too | Infrastructure to run and operate, separate from your application code |
Common Misconceptions
Section titled “Common Misconceptions”- “setImmediate chunking makes the work faster” — fact: it makes the event loop more responsive by interleaving other work in between chunks; the total CPU time for the heavy work itself does not decrease.
- “Worker threads share memory with the main thread automatically” — fact: by default, data passed to/from a
Workeris structured-cloned (copied); sharing actual memory requires an explicitSharedArrayBufferandAtomics. - “cluster gives you the same benefits as a reverse proxy” — fact:
clusteronly uses the cores on one machine; it does nothing for TLS termination, cross-machine scaling, or zero-downtime deploys the way a reverse proxy/load balancer setup does. - “performance.now() measures what the whole server experienced” — fact: it only measures the specific span you wrapped with
mark/measureort0/t1. Something else entirely (a dependency, GC, another request) can still be the actual source of latency — that’s whatmonitorEventLoopDelayis for.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does code offload a computation to a
Workerfor something that takes microseconds? The overhead of spinning up a thread and copying data can cost more than the computation itself. - Does a benchmark use
Date.now()instead ofperformance.now(), or measure across anawaitwithout accounting for other work that could run in between? - Is a
PerformanceObservercreated and.observe()d but never.disconnect()ed? It keeps a reference alive and keeps collecting entries for the lifetime of the process. - Does “add clustering” get suggested as a scaling fix without also asking whether a reverse proxy with multiple replicas (which also solves TLS, health checks, and multi-machine scaling) is the more complete answer?
🐛 Spot the bug
import { PerformanceObserver, performance } from 'node:perf_hooks';
function handleRequest(req, res) {const obs = new PerformanceObserver((list) => { console.log(list.getEntries()[0].duration);});obs.observe({ entryTypes: ['measure'] });
performance.mark('start');// ... handle the request ...performance.mark('end');performance.measure('request', 'start', 'end');
res.end('ok');}Show the answer
A new PerformanceObserver is created and .observe()d on every single request, but it is never .disconnect()ed. Each one keeps listening for new 'measure' entries for the rest of the process’s lifetime — after a thousand requests, a thousand observers all fire (and all log) on every subsequent performance.measure() call anywhere in the process, not just the one that created them. This is a listener-leak pattern: logging output multiplies over time, memory for the retained observers grows unbounded, and it gets slower to boot. The fix: create the observer once, outside the request handler (or use performance.getEntriesByName() synchronously after measure() instead of an observer at all when you don’t need async collection), and call .disconnect() when it’s genuinely done.