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

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

--cpu-prof, --cpu-prof-dir, --cpu-prof-name, --cpu-prof-interval

Stable (changelog: "now stable")

Added in v12.0.0 (--cpu-prof-interval in v12.2.0); marked stable in v22.4.0 and v20.16.0, so stable on all three lines.

--prof, --prof-process

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.

--perf-basic-prof, --perf-basic-prof-only-functions, --interpreted-frames-native-stack

V8 options accepted by Node.js (allowed in NODE_OPTIONS)

Linux perf support. Documented in the Learn guides rather than in the CLI reference.

node:perf_hooks: performance.mark(), measure(), PerformanceObserver, timerify(), eventLoopUtilization(), monitorEventLoopDelay(), createHistogram()

2 Stable

mark/measure follow the W3C User Timing specification (updated in v16.0.0). Histograms since v15.9.0 and v14.18.0.

perf_hooks.timerify(), perf_hooks.eventLoopUtilization() (top-level exports)

Stable

Aliases of the performance.* methods, added in v25.2.0 and v24.12.0. On Node.js 22 use performance.timerify() and performance.eventLoopUtilization().

monitorEventLoopDelay({ samplePerIteration })

Stable

Added in v26.5.0 and backported to v24.19.0; absent on Node.js 22.

Histogram statistics (welchTest(), ksTest(), cohensD(), burnRate(), confidence intervals, sliding windows)

Stable (part of the module)

burnRate(), ksTest(), welchTest() and friends arrived in v26.8.0 and v24.21.0; meanCI() and importHistogram() in v26.9.0; qrde() and createSlidingWindowHistogram() in v26.10.0. None exist on Node.js 22.

node:inspector/promises Session (CPU profiler over the inspector protocol)

1 Experimental

Added in v19.0.0. The callback-based node:inspector module is Stable.

Module compile cache: module.enableCompileCache(), getCompileCacheDir(), flushCompileCache(), NODE_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.

NODE_COMPILE_CACHE_PORTABLE=1 and the portable option

No longer experimental

Added in v25.0.0 and v24.12.0; not in the Node.js 22 documentation.

NODE_COMPILE_CACHE_READONLY=1 and the readOnly option

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.

NODE_DISABLE_COMPILE_CACHE=1

1.1 Active Development

Added in v22.8.0.

node:bench, --experimental-bench, --bench and the --bench-* options

1.0 Early Development (module); 1 Experimental (flags)

Added in v26.9.0. Not available on Node.js 24 or 22 (node: bad option: --experimental-bench).

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 CPU-bound catalogue builder
// 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}.

Writing a profile, then summarising it
node --cpu-prof --cpu-prof-dir=prof hot.js
ls prof
node top.mjs prof/*.cpuprofile
Output (Node.js 26.10.0)
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, self time per function from a .cpuprofile
// 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).

Profiling one section with node:inspector/promises
// 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);
Output of the script, then of top.mjs section.cpuprofile (Node.js 26.10.0)
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.

Profile, then process the log
node --prof hot.js
node --prof-process isolate-0x*-v8.log > processed.txt
Abridged output of --prof-process (Node.js 26.10.0, macOS; file paths shortened)
 [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.

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, from .cpuprofile to folded stacks
// 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}`);
Producing folded stacks and the heaviest line (Node.js 26.10.0)
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.

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.

A perf session, from recording to a flame graph (Linux only)
# 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.

Abridged perf output (Node.js 26.10.0, Linux)
$ 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, measures and an observer
// 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();
Output (Node.js 26.10.0; identical on 24.21.0 and 22.23.3 apart from milliseconds)
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.

Timing a hot function and keeping the distribution
// 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');
Output (Node.js 26.10.0)
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.

Comparing two implementations with Welch’s t-test
// 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));
Output (three runs; results vary)
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.

Counting GC pauses and reading start-up milestones
// 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();
Output (Node.js 26.10.0)
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.

Delay and utilisation around a blocking call
// 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();
Output (Node.js 26.10.0; 24.21.0 and 22.23.3 gave the same shape)
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.

Per-iteration sampling (Node.js 26.5+, 24.19+)
// 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();
Output (Node.js 26.10.0)
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.

Timing a heavy import with and without the cache
// 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`);
Cold run, then warm runs
NODE_COMPILE_CACHE=.cc node load.mjs --cache
NODE_COMPILE_CACHE=.cc node load.mjs --cache
Output (Node.js 26.10.0)
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:

Table 2. Compile cache behaviour
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; module.flushCompileCache() writes it earlier, useful before spawning other Node.js processes that should share it.

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 v26.10.0-…​, v24.21.0-…​ and v22.23.3-…​ folders).

Invalidation

By default a cache entry is tied to the absolute path of the module, so moving the project invalidates it. portable mode (the portable option or NODE_COMPILE_CACHE_PORTABLE=1) keys by path relative to the cache directory and drops the per-user suffix (the folder lost its -501 uid suffix in a run), so a cache built at image-build time can be shipped.

Read-only caches

NODE_COMPILE_CACHE_READONLY=1 or readOnly: true loads existing entries and never writes, and the directory is not created. A read-only run against a populated cache loaded in 41 ms on Node.js 24.21.0 and 45 ms on 26; against a missing directory the status was FAILED.

Worker threads

It only affects the current instance; set NODE_COMPILE_CACHE or call enableCompileCache() in the worker too.

Turning it off

NODE_DISABLE_COMPILE_CACHE=1 (status DISABLED). Do so when generating coverage: coverage can be less precise in functions deserialised from the cache.

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, two slugify implementations
// 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');
    });
  }
});
Running it and the output (Node.js 26.10.0)
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

Table 3. From symptom to fix
Symptom First tool Next step

High latency, CPU near 100% on one core

--cpu-prof under realistic load

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 (--trace-sync-io, see the debugging page); delay low: the time is in downstream services.

Slow start-up or short CLI runs

NODE_COMPILE_CACHE, performance.nodeTiming, --cpu-prof on start-up

Cache compiled code; lazy-load heavy modules with dynamic import().

Time spent in native code or the kernel

Linux perf with the perf map flags

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?"

node:bench (26.9+) or a histogram comparison

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.

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

Table 4. Profiling and performance changes by version
Version Change

v22.4.0, v20.16.0

The --cpu-prof flags are documented as stable.

v24.2.0

Event loop delay histograms are disposable (using).

v24.12.0, v25.2.0

timerify() and eventLoopUtilization() exported from node:perf_hooks itself.

v24.12.0, v25.0.0

Portable compile cache (portable option, NODE_COMPILE_CACHE_PORTABLE=1); the option was renamed from the unreleased path to directory.

v24.15.0, v25.4.0

The module compile cache is no longer experimental.

v24.19.0, v26.5.0

samplePerIteration for monitorEventLoopDelay().

v24.21.0, v26.8.0

Histogram statistics: welchTest(), ksTest(), cohensD(), cliffsD(), burnRate(), EWMA options.

v26.8.0

The compile cache readOnly option and NODE_COMPILE_CACHE_READONLY=1.

v26.9.0

node:bench and the --bench* options; histogram.export(), importHistogram() and meanCI().

v26.10.0

createSlidingWindowHistogram() and histogram.qrde().

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:

Table 5. Book coverage compared with current Node.js
Topic in the book Current state

--prof as the built-in profiler

--cpu-prof (stable since v22.4.0 and v20.16.0) writes a file that DevTools opens, with no second processing step; --prof-process remains for the V8 tick log.

console.time() for timing

Fine for a quick look; performance.mark()/measure(), timerify() and histograms give machine-readable results, percentiles and no string parsing.

No event loop metrics

monitorEventLoopDelay() and performance.eventLoopUtilization() are the standard health signals for a Node.js service.

Third-party load and benchmark tools only

Still useful for load generation; for micro-benchmarks core now has node:bench (26.9.0, Early Development) and histogram statistics.

Start-up cost not discussed

The module compile cache (NODE_COMPILE_CACHE) is no longer experimental and is a one-line win for CLIs.

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