Profiling & Performance
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked
against the official documentation on 2026-10-04, and every example was run on all three lines unless the text says otherwise (numbers in
outputs come from one run on an Apple silicon Mac and will differ on your machine). Profiling answers "where does the time go?". A CPU
profile tells you which functions burn processor time, a flame graph shows the same data as a picture, node:perf_hooks lets the application
measure itself, the event loop metrics tell you whether the single JavaScript thread is keeping up, the compile cache removes work from start-up,
and node:bench compares two implementations. The built-in profilers and node:perf_hooks are stable tools; node:bench is new in 26.9.0 and
Early Development. The running scenario is the Bookshelf application described in
Introduction & Architecture. Why a blocked event loop hurts every request is explained in
Don’t Block the Event Loop and The Event Loop; memory
profiling (heap snapshots, --heap-prof, GC traces) is the subject of the page on memory and leaks, and
Debugging covers attaching DevTools. Profile before you optimise: measure, change one thing, measure again.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
Stable (changelog: "now stable") |
Added in v12.0.0 ( |
|
Documented flags, no stability index |
Added in v2.0.0 and v5.2.0. They drive the V8 tick profiler; the log format belongs to V8 and can change between V8 versions. |
|
V8 options accepted by Node.js (allowed in |
Linux |
|
2 Stable |
|
|
Stable |
Aliases of the |
|
Stable |
Added in v26.5.0 and backported to v24.19.0; absent on Node.js 22. |
Histogram statistics ( |
Stable (part of the module) |
|
|
1 Experimental |
Added in v19.0.0. The callback-based |
Module compile cache: |
No longer experimental |
Environment variable added in v22.1.0, JavaScript API in v22.8.0; "no longer experimental" in v25.4.0 (v24.15.0 on the 24 line). The Node.js 22 documentation still labels it 1.1 Active Development. |
|
No longer experimental |
Added in v25.0.0 and v24.12.0; not in the Node.js 22 documentation. |
|
No longer experimental |
Added in v26.8.0; it also worked on 24.21.0 in the run behind this page, but the Node.js 24 documentation was not checked for it. |
|
1.1 Active Development |
Added in v22.8.0. |
|
1.0 Early Development (module); 1 Experimental (flags) |
Added in v26.9.0. Not available on Node.js 24 or 22 ( |
A workload worth profiling
Every CPU example below profiles the same small program, a stand-in for a Bookshelf start-up task that builds a catalogue and derives a digest per ISBN with a deliberately expensive synchronous call. The real culprit is obvious here; the point is to see how each tool reports it.
// hot.js - a deliberately CPU-bound Bookshelf helper
import { pbkdf2Sync, randomBytes } from 'node:crypto';
function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function hashIsbn(isbn) {
// Synchronous, CPU-heavy: this is what the profile should expose.
return pbkdf2Sync(isbn, randomBytes(16), 20_000, 64, 'sha512');
}
function buildCatalog(count) {
const books = [];
for (let i = 0; i < count; i++) {
const isbn = String(9780000000000 + i);
books.push({ slug: slugify(`Book Number ${i}: A Study`), digest: hashIsbn(isbn) });
}
return books;
}
console.log(buildCatalog(300).length, 'books');
The V8 CPU profiler: --cpu-prof
--cpu-prof starts V8’s sampling CPU profiler when the process starts and writes a .cpuprofile file when it exits. A sampling profiler
interrupts the thread at a fixed interval, records the current call stack, and later counts how often each function appeared at the top of the
stack (self time) or anywhere on it (total time). --cpu-prof-interval sets the interval in microseconds (default 1000), --cpu-prof-dir
the output directory (default: the current directory, or --diagnostic-dir if set) and --cpu-prof-name the file name; the name template
supports ${pid}.
node --cpu-prof --cpu-prof-dir=prof hot.js
ls prof
node top.mjs prof/*.cpuprofile
300 books
CPU.20261004.010656.80461.0.001.cpuprofile
98.7% 1004 ms pbkdf2Sync pbkdf2:67
0.8% 8 ms (idle) :0
0.1% 1 ms internalBinding realm:197
0.1% 1 ms consoleCall :0
0.1% 1 ms createSupportedAlgorithms util:572
0.1% 1 ms compileForInternalLoader realm:407
The default file name is CPU.<date>.<time>.<pid>.<tid>.<seq>.cpuprofile; the run above produced a three-digit sequence (001) where the
CLI reference shows a single digit. --cpu-prof-name 'hot.${pid}.cpuprofile' --cpu-prof-interval 500 worked the same on Node.js 24.21.0 and Node.js 22.23.3, where pbkdf2Sync took about 98.8% and 98.6% of the time. A .cpuprofile is plain JSON (its top-level keys are nodes, startTime, endTime, samples
and timeDeltas) in the format of the inspector’s Profiler domain, which DevTools-based viewers consume; loading it into a particular viewer was not
tested for this page, so check that tool’s documentation. The following script is all that was needed to produce the table above.
// top.mjs - summarise a .cpuprofile: self time per function
import { readFileSync } from 'node:fs';
const profile = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const selfUs = new Map(); // node id -> microseconds
profile.samples.forEach((id, i) => {
selfUs.set(id, (selfUs.get(id) ?? 0) + profile.timeDeltas[i]);
});
const rows = new Map(); // "function file:line" -> microseconds
for (const [id, us] of selfUs) {
const { functionName, url, lineNumber } = byId.get(id).callFrame;
const key = `${functionName || '(anonymous)'} ${url.split('/').pop()}:${lineNumber + 1}`;
rows.set(key, (rows.get(key) ?? 0) + us);
}
const total = [...rows.values()].reduce((a, b) => a + b, 0);
for (const [key, us] of [...rows].sort((a, b) => b[1] - a[1]).slice(0, 6)) {
console.log(`${((us / total) * 100).toFixed(1).padStart(5)}% ${(us / 1000).toFixed(0).padStart(5)} ms ${key}`);
}
Profile the code you care about, not the start-up: for a server, run it under --cpu-prof, drive realistic load, then stop it with a signal
your shutdown handler turns into a normal exit (see Process, Signals & Exit Codes),
because the profile is written when the process exits normally. To profile just one section of a running process, drive the V8 profiler from
inside the process with the inspector’s promise API (Experimental, needs no flag).
// session-prof.mjs - profile only one section of a running process
import { Session } from 'node:inspector/promises';
import { writeFile } from 'node:fs/promises';
function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function slugifyAll(count) {
const slugs = [];
for (let i = 0; i < count; i++) slugs.push(slugify(`Book Number ${i}: A Study in Scarlet`));
return slugs;
}
const session = new Session();
session.connect(); // same thread, so profiling starts and stops where you say
await session.post('Profiler.enable');
await session.post('Profiler.start');
slugifyAll(500_000); // the section under investigation
const { profile } = await session.post('Profiler.stop');
await writeFile('section.cpuprofile', JSON.stringify(profile));
session.disconnect();
console.log('nodes:', profile.nodes.length, 'samples:', profile.samples.length);
nodes: 11 samples: 106
46.7% 64 ms slugify session-prof.mjs:5
23.0% 31 ms (garbage collector) :0
10.9% 15 ms RegExp: [^a-z0-9]+ :0
6.4% 9 ms RegExp: ^-|-$ :0
The tick profiler: --prof and --prof-process
--prof is the older built-in profiler. It writes a V8 log named isolate-0x<address>-<pid>-v8.log in the current directory, one line per
event: each sampled "tick" plus compilation events. The log is not meant to be read directly; node --prof-process <log> turns it into a
summary and a "bottom up" call tree where indented lines read as "was called by". Redirect the output to a file, because it is long.
node --prof hot.js
node --prof-process isolate-0x*-v8.log > processed.txt
[Summary]:
ticks total nonlib name
0 0.0% 0.0% JavaScript
782 99.5% 99.5% C++
0 0.0% 0.0% GC
0 0.0% Shared libraries
4 0.5% Unaccounted
[Bottom up (heavy) profile]:
ticks parent name
691 92.5% JS: ^pbkdf2Sync node:internal/crypto/pbkdf2:67:20
691 100.0% JS: ^hashIsbn file://…/hot.js:8:18
691 100.0% JS: ^buildCatalog file://…/hot.js:13:22
691 100.0% Script: ~<anonymous> file://…/hot.js:1:1
Read the summary first: nearly all ticks are in "C", meaning native code (here OpenSSL, called from the JavaScript wrapper) rather than your
JavaScript, so rewriting the JavaScript would not help. On macOS the C lines of this report were dominated by one absurdly long, wrongly
resolved symbol name, an observation about this machine and not a Node.js rule; on Node.js 22.23.3 the same run put 99.9% of the ticks under
"Unaccounted". The , ~ and ^ prefixes in front of function names tell you which code tier V8 was running: V8’s own profiling guide says
a leading marks a function optimised by TurboFan, and the ~ and ^ markers seen here were not explained in the pages this page cites.
node --prof-process --preprocess <log> prints JSON instead of text, for tooling.
Source: Node.js Learn: Profiling Node.js Applications, Node.js API: Command-line options (--prof, --prof-process), V8: Using V8’s sample-based profiler
For choosing between the two built-in profilers: --cpu-prof produces one self-contained JSON file that DevTools and other viewers open, has
a configurable sampling interval and no processing step, so it is the default recommendation. --prof still has a role when you want V8’s
view of compilation events, or when you follow the Learn guide’s walkthrough, and --prof-process is bundled with the Node.js binary so no
extra install is needed.
Flame graphs
A flame graph draws the sampled call stacks as nested boxes: the x axis is the share of samples (not time order), the y axis is stack depth,
and a wide box at the top of a tower is a function that was on the CPU a lot. Two kinds of input exist. The first is a .cpuprofile, which
viewers such as Chrome DevTools render directly. The second is a file of folded stacks, one line per distinct stack with the stack frames
separated by semicolons and a sample count at the end, which is the input format of Brendan Gregg’s flamegraph.pl and of several web
viewers. The script below converts a .cpuprofile into that folded format (here weighting by microseconds instead of sample counts).
// fold.mjs - turn a .cpuprofile into "folded stacks" for flamegraph.pl or speedscope
import { readFileSync } from 'node:fs';
const { nodes, samples, timeDeltas } = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const byId = new Map(nodes.map((n) => [n.id, n]));
const parent = new Map();
for (const n of nodes) for (const c of n.children ?? []) parent.set(c, n.id);
const label = (id) => {
const { functionName, url, lineNumber } = byId.get(id).callFrame;
return `${functionName || '(anonymous)'} ${url.split('/').pop()}:${lineNumber + 1}`;
};
const folded = new Map();
samples.forEach((id, i) => {
const stack = [];
for (let n = id; n !== undefined; n = parent.get(n)) stack.push(label(n));
const key = stack.reverse().join(';');
folded.set(key, (folded.get(key) ?? 0) + timeDeltas[i]);
});
for (const [stack, us] of folded) console.log(`${stack} ${us}`);
node fold.mjs prof/*.cpuprofile > folded.txt
sort -t' ' -k2 -n -r folded.txt | head -n 1
(root) :0;run module_job:558;evaluate :0;(anonymous) hot.js:1;buildCatalog hot.js:13;hashIsbn hot.js:8;pbkdf2Sync pbkdf2:67 1003715
Read it left to right as the call chain (buildCatalog called hashIsbn, which called pbkdf2Sync) followed by the weight. Pipe such a
file to flamegraph.pl to get an SVG; that step was not run for the folded file above, but the equivalent perf pipeline was run end to end in
the next section.
Source: Node.js Learn: Flame Graphs
Linux perf and system-level flame graphs
perf is the Linux kernel’s profiler. It samples the whole process, so unlike the V8 profilers it sees native frames, OpenSSL, libuv and
the kernel, at the price of needing help to name JavaScript functions: JIT-compiled code has no symbols on disk. Node.js can write a
map from addresses to function names, /tmp/perf-<pid>.map, which perf reads. --perf-basic-prof maps every code object and keeps
appending to the file, which can grow without bound; --perf-basic-prof-only-functions writes less and is the one the Learn guide
recommends for production profiling. --interpreted-frames-native-stack gives interpreted functions their own entry points so their names
show up too. Without these flags you still get a graph, but most boxes read v8::Function::Call.
# record 99 samples per second with call graphs, from the first instruction
perf record -F 99 -g -- node --perf-basic-prof-only-functions --interpreted-frames-native-stack hot.js
# or attach to a process that is already running, for three seconds
perf record -F 99 -p "$(pgrep -n node)" -g -- sleep 3
perf script > perfs.out
./FlameGraph/stackcollapse-perf.pl perfs.out | ./FlameGraph/flamegraph.pl --colors=js > profile.svg
On macOS there is no perf; the Node.js flags still ran there and wrote nothing but the map file in /tmp. The commands above were run on Linux
in a privileged container of the official node:26-slim image (Node.js 26.10.0, perf 6.12.111, arm64, inside Docker Desktop’s virtual
machine, where the sampling event shown by an earlier cycles:u attempt was the software event task-clock). The report listed
native code first, as expected for a program that spends its time in OpenSSL, and the JavaScript callers were named thanks to the map file.
$ perf report --stdio --no-children -g none --sort sym
63.11% [.] sha512_block_armv8
7.77% [.] EVP_DigestFinal_ex
5.83% [.] EVP_MD_CTX_copy_ex
$ perf script -F ip,sym | grep -E 'hashIsbn|buildCatalog|pbkdf2Sync'
ffff9684ae20 JS:^buildCatalog file:///tmp/hot.js:13:22
ffff9684ac10 JS:^hashIsbn file:///tmp/hot.js:8:18
ffff9684a96c JS:^pbkdf2Sync node:internal/crypto/pbkdf2:67:20
The Learn guide also shows a sed command that removes engine internals from perfs.out before collapsing. Its patterns match the older
LazyCompile: prefix; the map written by Node.js 26 labels functions JS:~name and JS:^name, as the output above shows, so some filter
patterns match nothing any more. The full pipeline (filter, stackcollapse-perf.pl, flamegraph.pl --colors=js) still produced a 40 KB
SVG from this profile. Run the unfiltered graph too if the filtered one looks odd. In containers perf needs privileges (the run above used
--privileged), so on a production cluster prefer a profile you can capture inside the process, such as --cpu-prof or the inspector.
V8 also documents its own --perf-prof flag with perf inject --jit for a more complete symbol pipeline; that route was not tried here.
Measuring from inside: perf_hooks
node:perf_hooks implements parts of the W3C High Resolution Time, Performance Timeline, User Timing and Resource Timing specifications
plus Node.js extensions. performance.now() gives a monotonic millisecond timestamp (use it instead of Date.now() for durations);
performance.mark(name) records a named instant and performance.measure(name, start, end) records the duration between two marks, or
between two timestamps passed in an options object. Entries land in the Performance Timeline, where a PerformanceObserver receives them
asynchronously. Marks and measures accumulate until you clear them with clearMarks() and clearMeasures(), so a long-running server that
measures every request must clear them or the timeline keeps growing.
// marks.mjs - User Timing around two phases of a Bookshelf import
import { performance, PerformanceObserver } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.entryType} ${entry.name}: ${entry.duration.toFixed(1)} ms`, entry.detail ?? '');
}
});
observer.observe({ entryTypes: ['measure'] });
performance.mark('import:start');
await sleep(40); // pretend to parse a CSV
performance.mark('import:parsed', { detail: { rows: 1200 } });
await sleep(25); // pretend to insert into SQLite
performance.mark('import:stored');
performance.measure('parse', 'import:start', 'import:parsed');
performance.measure('store', 'import:parsed', 'import:stored');
performance.measure('import (total)', { start: 'import:start', end: 'import:stored', detail: { source: 'books.csv' } });
await sleep(0); // let the observer callback run
console.log(performance.getEntriesByType('mark').map((m) => m.name));
performance.clearMarks();
performance.clearMeasures();
observer.disconnect();
measure parse: 41.2 ms
measure import (total): 67.3 ms { source: 'books.csv' }
measure store: 26.2 ms
[ 'import:start', 'import:parsed', 'import:stored' ]
In this run the entries reached the observer ordered by start time, not by the order the measure() calls were made, and the observer runs after the current
synchronous code finishes, which is why the example yields before reading. Naming a start mark that does not exist throws.
timerify and histograms
performance.timerify(fn) returns a wrapper that times every call. The timing is delivered as 'function' entries to an observer (the entry’s
detail holds the call arguments), and, more usefully for hot functions, can be recorded into a histogram passed as the histogram option,
so you keep the whole distribution instead of one number. If fn returns a promise the duration is reported when it settles. A histogram
made by createHistogram() stores integers (nanoseconds when fed by timerify) and answers count, min, max, mean, stddev and
percentile(p) with bounded memory, which is the reason to prefer it over an array of timings. Mind the cost: observing 'function' entries
on a function called a hundred thousand times creates a hundred thousand entries, so use the histogram alone when you only need statistics.
// timerify.mjs - time a function on every call and keep the distribution
import { createHistogram, performance, PerformanceObserver } from 'node:perf_hooks';
function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
const histogram = createHistogram(); // values are nanoseconds
const timed = performance.timerify(slugify, { histogram });
const observer = new PerformanceObserver((list) => {
const [first] = list.getEntries();
console.log(`${first.entryType} ${first.name}: ${first.duration.toFixed(4)} ms, args=${JSON.stringify(first.detail)}`);
observer.disconnect();
});
observer.observe({ entryTypes: ['function'] });
for (let i = 0; i < 100_000; i++) timed(`Book Number ${i}: A Study`);
const ms = (ns) => (ns / 1e6).toFixed(4);
console.log('calls ', histogram.count);
console.log('p50 ', ms(histogram.percentile(50)), 'ms');
console.log('p99 ', ms(histogram.percentile(99)), 'ms');
console.log('max ', ms(histogram.max), 'ms');
calls 100000
p50 0.0002 ms
p99 0.0008 ms
max 1.9866 ms
function slugify: 0.0695 ms, args=["Book Number 0: A Study"]
The observer line prints last because entries are delivered after the synchronous loop ends. The maximum is orders of magnitude above the median: averages hide exactly the outliers (a garbage collection, a JIT compile) that users notice, so report percentiles.
Histograms also grew a statistics toolkit recently, handy for comparing two builds. welchTest() compares two means, cohensD() and cliffsD()
give effect sizes, ksTest() compares distributions, percentileCI() and (from 26.9.0) meanCI() give confidence intervals, and burnRate() and ewmaMean
track a service-level objective. They need Node.js 26.8.0 or later, or 24.21.0 on the 24 line, and do not exist on Node.js 22.
// ab.mjs - compare two implementations with histograms (Node.js 26.8+, 24.21+)
import { createHistogram, performance } from 'node:perf_hooks';
const replaceSlug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const splitSlug = (t) => t.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join('-');
const a = createHistogram();
const b = createHistogram();
const record = (histogram, fn, input) => {
const start = performance.now();
fn(input);
histogram.record(Math.max(1, Math.round((performance.now() - start) * 1e6))); // nanoseconds, never 0
};
for (let i = 0; i < 20_000; i++) {
const title = `Book Number ${i}: A Study in Scarlet`;
record(a, replaceSlug, title); // interleave to share the same JIT and GC conditions
record(b, splitSlug, title);
}
const result = a.welchTest(b);
console.log('mean replace', a.mean.toFixed(0), 'ns; mean split', b.mean.toFixed(0), 'ns');
console.log('p-value', result.pValue.toExponential(2), "Cohen's d", a.cohensD(b).toFixed(2));
Node.js 26.10.0
mean replace 232 ns; mean split 335 ns
p-value 5.52e-12 Cohen's d -0.07
Node.js 24.21.0
mean replace 255 ns; mean split 273 ns
p-value 2.27e-1 Cohen's d -0.01
Node.js 22.23.3: TypeError, a.welchTest is not a function
The two lines show why a p-value is not a verdict: the same code gave a convincing result on one run and an inconclusive one on another,
and Cohen’s d says the difference, even when "significant", is small relative to the noise. Treat such numbers as a prompt to measure
again with a proper benchmark runner, covered below. The observer-free histogram API is also how you export latency from a service: record
request durations, and read percentile(99) on a timer.
Garbage collection entries and start-up milestones
Two more entry sources are worth knowing. A PerformanceObserver for the 'gc' entry type receives one entry per collection, with kind
in detail (constants.NODE_PERFORMANCE_GC_MINOR for a scavenge, NODE_PERFORMANCE_GC_MAJOR, NODE_PERFORMANCE_GC_INCREMENTAL,
NODE_PERFORMANCE_GC_WEAKCB, and NODE_PERFORMANCE_GC_MINOR_MARK_SWEEP in the Node.js 26 documentation). The older top-level kind and flags properties of such an entry are
deprecated, so read entry.detail. performance.nodeTiming reports when the process, V8 and the event loop started.
// gc-timing.mjs - garbage-collection entries and Node.js start-up milestones
import { constants, performance, PerformanceObserver } from 'node:perf_hooks';
import { setImmediate as tick } from 'node:timers/promises';
const kinds = new Map([
[constants.NODE_PERFORMANCE_GC_MAJOR, 'major'],
[constants.NODE_PERFORMANCE_GC_MINOR, 'minor (scavenge)'],
[constants.NODE_PERFORMANCE_GC_INCREMENTAL, 'incremental'],
[constants.NODE_PERFORMANCE_GC_WEAKCB, 'weak callbacks'],
]);
let pauses = 0;
let totalMs = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
pauses++;
totalMs += entry.duration;
if (pauses <= 3) console.log(`gc ${kinds.get(entry.detail.kind) ?? entry.detail.kind}: ${entry.duration.toFixed(2)} ms`);
}
});
observer.observe({ entryTypes: ['gc'] });
let keep;
for (let i = 0; i < 200; i++) {
keep = Array.from({ length: 50_000 }, (_, n) => ({ n, title: `Book ${n}` })); // short-lived garbage
await tick();
}
await tick();
console.log(`${pauses} GC entries, ${totalMs.toFixed(1)} ms in total`, keep.length);
const { bootstrapComplete, loopStart } = performance.nodeTiming;
console.log('ms since process start: bootstrap', bootstrapComplete.toFixed(1), 'loop start', loopStart.toFixed(1));
observer.disconnect();
gc minor (scavenge): 0.27 ms
gc minor (scavenge): 0.24 ms
gc minor (scavenge): 0.23 ms
53 GC entries, 40.8 ms in total 50000
ms since process start: bootstrap 8.0 loop start 11.8
On the same program Node.js 24 recorded 33 entries (24.7 ms) and Node.js 22 recorded 112 entries (120.2 ms), a reminder that collector behaviour differs by V8 version, so compare runs on the same line. GC tuning and heap analysis continue in the page on memory and leaks.
Event loop delay and utilisation
Two metrics describe whether the single JavaScript thread keeps up. Event loop delay (ELD) is how late a timer fires:
monitorEventLoopDelay({ resolution }) samples with a repeating timer (default every 10 ms) and records the lateness into a histogram in
nanoseconds; call enable() to start and disable() to stop (an ELDHistogram is also disposable since v24.2.0). Event loop utilisation
(ELU) is the fraction of time the loop was busy rather than waiting in the OS for events: performance.eventLoopUtilization() returns
cumulative idle, active and utilization, and passing an earlier result returns the difference since then. ELU is not CPU usage: a loop
blocked in a synchronous call, even a sleep, counts as 100% utilised.
// loop-health.mjs - event loop delay (ELD) and utilisation (ELU) around a blocking task
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
import { pbkdf2Sync } from 'node:crypto';
const delay = monitorEventLoopDelay({ resolution: 10 }); // samples are nanoseconds
delay.enable();
const ms = (ns) => (ns / 1e6).toFixed(1);
const report = (label, elu) =>
console.log(`${label} p50=${ms(delay.percentile(50))} ms p99=${ms(delay.percentile(99))} ms ` +
`max=${ms(delay.max)} ms ELU=${elu.utilization.toFixed(3)}`);
const before = performance.eventLoopUtilization();
await sleep(300); // an idle loop
report('idle ', performance.eventLoopUtilization(before));
const beforeBlock = performance.eventLoopUtilization();
pbkdf2Sync('secret', 'salt', 400_000, 64, 'sha512'); // synchronous: nothing else runs
await sleep(50); // give the sampling timer a chance to fire
report('blocked', performance.eventLoopUtilization(beforeBlock));
delay.disable();
Source: Node.js API: perf_hooks.monitorEventLoopDelay(), Node.js API: performance.eventLoopUtilization()
idle p50=11.0 ms p99=11.1 ms max=11.1 ms ELU=0.003
blocked p50=11.0 ms p99=99.7 ms max=99.7 ms ELU=0.653
At a 10 ms resolution an idle loop reports about 11 ms: the timer interval itself shows up in the samples (an observation on this machine),
so judge a loop by how far the percentiles move from the idle baseline, not by the absolute value. The blocking call stretched the maximum to
about a hundred milliseconds and pushed ELU from near zero to 0.65. Calling reset() on the histogram immediately before a long blocking section lost that
sample in a run of this page’s code, so take the readings you need before resetting. Since 26.5.0 (and 24.19.0)
monitorEventLoopDelay({ samplePerIteration: true }) samples once per loop iteration instead of by timer; the two modes give significantly
different numbers and must not be compared with each other, and the per-iteration mode does not keep an idle loop alive.
// iter.mjs - per-iteration event loop delay sampling (Node.js 26.5+)
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
import { pbkdf2Sync } from 'node:crypto';
const delay = monitorEventLoopDelay({ samplePerIteration: true });
delay.enable();
for (let i = 0; i < 20; i++) await sleep(5);
pbkdf2Sync('secret', 'salt', 400_000, 64, 'sha512');
await sleep(5);
console.log('samples', delay.count, 'p50', (delay.percentile(50) / 1e6).toFixed(2), 'ms', 'max', (delay.max / 1e6).toFixed(1), 'ms');
delay.disable();
samples 22 p50 0.67 ms max 68.2 ms
In production, export percentile(99) of delay and the ELU of a window as metrics (see the page on diagnostic reports, logging and
observability), alert when delay or ELU stays high, and use ELU to decide whether to add processes with
Cluster & Scaling or threads with Worker Threads:
high ELU with low CPU points at blocking synchronous work, high ELU with high CPU at real computation. ELU is available inside worker threads too, where it describes that thread’s own loop.
The module compile cache
Before JavaScript runs, V8 must compile it. The module compile cache stores V8’s code cache on disk, so the next process can skip most
of that work for CommonJS, ES module and TypeScript sources alike. The first load of a module graph is slightly slower (it also writes the
cache); later loads of unchanged modules are faster. Enable it with NODE_COMPILE_CACHE=<dir> in the environment or by calling
module.enableCompileCache() as early as possible (it never throws: inspect the returned status, which is one of
module.constants.compileCacheStatus: FAILED, ENABLED, ALREADY_ENABLED or DISABLED). Without a directory it uses a folder under
os.tmpdir() or the environment variable. This example loads a large dependency (the typescript package, version 5.9.3, installed with
npm in a scratch project) and times it.
// load.mjs - how long does it take to load a large dependency?
import { constants, enableCompileCache, getCompileCacheDir } from 'node:module';
if (process.argv.includes('--cache')) {
const { status, directory, message } = enableCompileCache(); // honours NODE_COMPILE_CACHE
const name = Object.keys(constants.compileCacheStatus).find((k) => constants.compileCacheStatus[k] === status);
console.log(`compile cache: ${name}`, directory ?? message);
}
const start = performance.now();
const { default: ts } = await import('typescript');
console.log(`loaded typescript ${ts.version} in ${(performance.now() - start).toFixed(0)} ms`);
NODE_COMPILE_CACHE=.cc node load.mjs --cache
NODE_COMPILE_CACHE=.cc node load.mjs --cache
compile cache: ALREADY_ENABLED ./.cc/v26.10.0-arm64-310b2ce3-501
loaded typescript 5.9.3 in 93 ms
compile cache: ALREADY_ENABLED ./.cc/v26.10.0-arm64-310b2ce3-501
loaded typescript 5.9.3 in 45 ms
The status is ALREADY_ENABLED because the environment variable enabled the cache before the script ran. The cold-to-warm times were
93 to 45 ms on Node.js 26, 94 to 43 ms on 24.21.0 and 151 to 90 ms on 22.23.3 (on Node.js 22 the JavaScript API takes only a directory
string, and the documentation labels it Active Development). Facts to remember:
| Topic | Behaviour |
|---|---|
Where the cache is written |
The code cache is generated when a module loads but written to disk when the process is about to exit; |
Layout and versions |
The on-disk layout is an implementation detail. A cache is only reusable by the same Node.js version; different versions keep separate subdirectories
side by side (the run above produced |
Invalidation |
By default a cache entry is tied to the absolute path of the module, so moving the project invalidates it. |
Read-only caches |
|
Worker threads |
It only affects the current instance; set |
Turning it off |
|
It pays off for command-line tools and short-lived processes (a CLI, a serverless function, test workers) whose time goes into loading code; a long-running server benefits only at start-up. Create the cache in a build step and ship it with the application for the best effect.
node:bench (Early Development)
node:bench is a benchmark runner in core, new in Node.js 26.9.0, and the only part of this page that is not Stable. The module is marked
Stability 1.0 (Early Development) and the CLI flags 1 (Experimental), it is only available when Node.js starts with --experimental-bench,
it can only be imported with the node: scheme, and it does not exist on Node.js 24 or 22. A benchmark calls b.start() and b.end(operations)
exactly once around the measured work (or b.record(sample) with an externally measured sample), so setup and cleanup stay outside the timing.
The runner executes benchmarks serially, warms them up, collects samples, and the default spec reporter prints the mean rate, a 95% confidence
interval, the median and warnings (noisy for a coefficient of variation above 5%, skewed for an absolute skewness above 1).
// slug.bench.mjs - compare two slugify implementations with node:bench
import { bench, suite } from 'node:bench';
const titles = Array.from({ length: 1000 }, (_, i) => `Book Number ${i}: A Study in Scarlet`);
const OPERATIONS = titles.length;
const regexSlug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const splitSlug = (t) => t.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join('-');
suite('slugify', () => {
for (const [name, fn] of [['replace', regexSlug], ['split', splitSlug]]) {
bench(name, { samples: 30 }, (b) => {
let total = 0;
b.start();
for (const title of titles) total += fn(title).length; // keep every result observable
b.end(OPERATIONS);
if (total === 0) throw new Error('unexpected empty result');
});
}
});
Source: Node.js API: suite(), Node.js API: bench()
node --experimental-bench --bench slug.bench.mjs
(node:10126) ExperimentalWarning: Benchmarks is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
benchmark | samples | mean rate | 95% CI | median rate | warning
replace | 30 | 5.07M ops/s | [4.68M ops/s, 5.45M ops/s] | 5.49M ops/s | noisy, skewed
split | 30 | 3.88M ops/s | [3.68M ops/s, 4.07M ops/s] | 3.92M ops/s | noisy, skewed
2 completed, 0 failed, 0 skipped
Useful options:
--bench-name-pattern=<regex> runs only matching benchmarks (the others are reported as skipped), --bench-samples=<n> and --bench-warmup=<n>
override the counts, --bench-reporter=json emits newline-delimited JSON events (bench:plan, bench:start, bench:sample, bench:complete,
bench:summary) and --bench-isolation=process (the default) runs each file in its own child process. Benchmark files declare benchmarks and must not
call run() themselves; embedders can use createRunner() instead.
The documentation’s own warnings are the lesson. A statistically tidy result does not prove you measured what you meant: make the result of
the measured work observable (the example sums the slug lengths and checks the total, so the engine cannot discard the calls), batch enough
operations per sample to amortise timer reads, look at the raw samples for GC pauses or insufficient warm-up, and confirm surprising results
with a differently shaped benchmark. node:bench does not choose a baseline or give a pass/fail verdict; it exposes samples so a higher-level tool
can run the statistics, and the histogram methods above are the same toolkit.
A profiling workflow
| Symptom | First tool | Next step |
|---|---|---|
High latency, CPU near 100% on one core |
|
Open the profile or the folded stacks; optimise the widest function, or move the work to a worker thread. |
High latency, CPU low |
Event loop delay and ELU |
Delay high and ELU high with low CPU: look for synchronous calls ( |
Slow start-up or short CLI runs |
|
Cache compiled code; lazy-load heavy modules with dynamic |
Time spent in native code or the kernel |
Linux |
Check whether it is crypto, compression, DNS or file I/O; the libuv thread pool may be saturated (see the event loop page). |
"Is A faster than B?" |
|
Keep raw samples, run several times, check the benchmark measures the intended work. |
Memory growth or long GC pauses |
GC entries, then heap profiling |
Continue in the page on memory and leaks. |
Source: Node.js Learn: Poor Performance, Node.js Learn: Node.js, the difference between development and production
Three habits matter more than any flag. Profile a production-like build (NODE_ENV=production, the same Node.js version, the same data volume).
Profile under load that resembles real traffic, because an idle process profiles as idle. And keep every number in its context: sampling profilers
perturb the program, a single run proves little, and a laptop, a container and a VM disagree.
What changed recently
| Version | Change |
|---|---|
v22.4.0, v20.16.0 |
The |
v24.2.0 |
Event loop delay histograms are disposable ( |
v24.12.0, v25.2.0 |
|
v24.12.0, v25.0.0 |
Portable compile cache ( |
v24.15.0, v25.4.0 |
The module compile cache is no longer experimental. |
v24.19.0, v26.5.0 |
|
v24.21.0, v26.8.0 |
Histogram statistics: |
v26.8.0 |
The compile cache |
v26.9.0 |
|
v26.10.0 |
|
The Learn guides and older articles still describe the pipeline as it was when LazyCompile: appeared in perf output and when external
tools were needed to read V8 ticks; the tools they point to (the V8 tick processor, perf) are still valid, but flags, labels and file
names have moved on as shown above.
Where the books are outdated
Samer Buna’s Efficient Node.js (O’Reilly, 2025) mentions the built-in --prof profiler and the DevTools profiler in its debugging chapter, and
times asynchronous code with console.time and console.timeEnd. Both still work. Missing or superseded for current Node.js:
| Topic in the book | Current state |
|---|---|
|
|
|
Fine for a quick look; |
No event loop metrics |
|
Third-party load and benchmark tools only |
Still useful for load generation; for micro-benchmarks core now has |
Start-up cost not discussed |
The module compile cache ( |
Jonathan Wexler’s Node.js Projects (O’Reilly, 2025) is a project-building book in which profiling is not a topic, so it stays a source for the Bookshelf-style scenarios rather than for this page.
References
-
Node.js Learn: Profiling Node.js Applications, https://nodejs.org/learn/getting-started/profiling
-
Node.js Learn: Flame Graphs, https://nodejs.org/learn/diagnostics/flame-graphs
-
Node.js Learn: Poor Performance, https://nodejs.org/learn/diagnostics/poor-performance
-
Node.js Learn: Using Linux Perf, https://nodejs.org/learn/diagnostics/poor-performance/using-linux-perf
-
Node.js Learn: Node.js, the difference between development and production, https://nodejs.org/learn/getting-started/nodejs-the-difference-between-development-and-production
-
Node.js API: Performance measurement APIs, https://nodejs.org/docs/latest/api/perf_hooks.html
-
Node.js API: performance.timerify(), https://nodejs.org/docs/latest/api/perf_hooks.html#performancetimerifyfn-options
-
Node.js API: perf_hooks.createHistogram(), https://nodejs.org/docs/latest/api/perf_hooks.html#perf_hookscreatehistogramoptions
-
Node.js API: perf_hooks.monitorEventLoopDelay(), https://nodejs.org/docs/latest/api/perf_hooks.html#perf_hooksmonitoreventloopdelayoptions
-
Node.js API: performance.eventLoopUtilization(), https://nodejs.org/docs/latest/api/perf_hooks.html#performanceeventlooputilizationutilization1-utilization2
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: --cpu-prof, https://nodejs.org/docs/latest/api/cli.html#--cpu-prof
-
Node.js API: --cpu-prof-dir, https://nodejs.org/docs/latest/api/cli.html#--cpu-prof-dir
-
Node.js API: Modules: node:module API (module compile cache), https://nodejs.org/docs/latest/api/module.html
-
Node.js API: module.enableCompileCache(), https://nodejs.org/docs/latest/api/module.html#moduleenablecompilecacheoptions
-
Node.js API: module.constants.compileCacheStatus, https://nodejs.org/docs/latest/api/module.html#moduleconstantscompilecachestatus
-
Node.js API: Benchmark runner, https://nodejs.org/docs/latest/api/bench.html
-
Node.js API: suite(), https://nodejs.org/docs/latest/api/bench.html#suitename-options-fn
-
Node.js API: bench(), https://nodejs.org/docs/latest/api/bench.html#benchname-options-fn
-
Node.js API: Inspector, https://nodejs.org/docs/latest/api/inspector.html
-
Node.js API: Inspector, CPU profiler, https://nodejs.org/docs/latest/api/inspector.html#cpu-profiler
-
V8: Using V8’s sample-based profiler, https://v8.dev/docs/profile
-
V8: Linux perf integration, https://v8.dev/docs/linux-perf
-
V8: Improved code caching, https://v8.dev/blog/code-caching-for-devs
-
Brendan Gregg: Flame graphs, https://www.brendangregg.com/flamegraphs.html
-
FlameGraph tools (GitHub), https://github.com/brendangregg/FlameGraph
-
W3C User Timing, https://w3c.github.io/user-timing/
-
Samer Buna, Efficient Node.js, O’Reilly Media, 2025, https://www.oreilly.com/library/view/efficient-node-js/9781098145187/
-
Jonathan Wexler, Node.js Projects, O’Reilly Media, 2025, https://www.oreilly.com/library/view/node-js-projects/9781098173135/