Skip to content

Performance & perf_hooks

“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() 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 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 leaks
obs.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:

Node.js
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:

Terminal window
node --expose-gc gc-observer.mjs
gc entry seen: true
// @skip-verify requires --expose-gc, not passed by default here
import { 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:

Node.js
Output (verified on Node 24)
saw event loop delay: true
min <= mean <= max: true

h.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().

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

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 duration
function 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 setImmediate
async 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';

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:

Terminal window
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)
Terminal window
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.

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:

Node.js
Output (verified on Node 24)
worker result: 832040
main thread was free to do other work while this ran

A 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.

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:

Node.js
Output (verified on Node 24)
primary pid, forking 2 workers
worker running, exiting immediately
worker running, exiting immediately
all workers exited, primary done

In 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.

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
Measure, find the real hot path, fix it, then measure again -- never guess

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.

ChoiceBenefitCost
setImmediate/await chunkingSimple, no new thread, keeps total work on one coreSame total CPU time, just interleaved — does not add throughput
worker_threadsTrue parallelism, main thread stays responsive for I/OStartup overhead per worker, no shared memory by default, more code to coordinate
cluster (multi-process)Uses every core on one machine, minimal code changeEach worker process has its own memory (no shared in-process cache), still one machine’s ceiling
Reverse proxy + N replicasScales across machines, handles TLS/health checks/rolling restarts tooInfrastructure to run and operate, separate from your application code
  • “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 Worker is structured-cloned (copied); sharing actual memory requires an explicit SharedArrayBuffer and Atomics.
  • “cluster gives you the same benefits as a reverse proxy” — fact: cluster only 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/measure or t0/t1. Something else entirely (a dependency, GC, another request) can still be the actual source of latency — that’s what monitorEventLoopDelay is for.
  • Does code offload a computation to a Worker for 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 of performance.now(), or measure across an await without accounting for other work that could run in between?
  • Is a PerformanceObserver created 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.

What unit does performance.now() return?
Why is performance.now() preferred over Date.now() for benchmarking?
What problem does a long-running CPU-bound loop cause in Node.js?
Which technique yields control back to the event loop periodically during a large loop?
What does this print (roughly) and why? const obs = new PerformanceObserver((list) => { console.log(list.getEntries().length > 0); }); obs.observe({ entryTypes: ['gc'] }); global.gc();
A worker_threads Worker computes a value and calls parentPort.postMessage(result). Does the main thread automatically see updates if the worker later mutates a plain object it captured in its closure?
Why does a reverse proxy with N replicas usually replace, rather than complement, using node:cluster alone in production?