Performance and Profiling

Every Node.js program eventually hits a wall: a request handler that gets slower as traffic grows, a CPU-bound calculation that freezes the whole server, or memory usage that creeps up until the process crashes. Performance work in Node.js is not about guessing — it is about measuring where time and memory actually go, using the tools the runtime and V8 ship with, and then fixing the specific bottleneck the data points to. This lesson covers how Node’s single-threaded event loop affects performance, the built-in profiling APIs and CLI flags, and the patterns that keep an application fast under load.

Overview: How Node.js Performance Works

Node.js runs your JavaScript on a single thread. I/O — reading files, querying a database, making HTTP requests — is handed off to the operating system or to libuv’s thread pool, and your code is notified with a callback or resolved promise when the result is ready. This is why Node handles thousands of concurrent I/O-bound connections efficiently: the main thread is never blocked waiting on I/O, it just keeps processing the event loop’s queue of callbacks.

The catch is that any synchronous, CPU-bound work — a big loop, a large JSON.parse, a synchronous file read, an expensive regular expression — runs directly on that same single thread. While it runs, nothing else can: no other request is handled, no timer fires, no I/O callback executes. A single slow synchronous operation in a request handler doesn’t just slow down that one request, it stalls every concurrent request the process is serving. This is the single most important performance fact about Node.js: the event loop must stay free, and CPU-heavy work needs to be chunked, streamed, or moved off the main thread entirely with worker_threads or a separate process.

Underneath, V8 (the JavaScript engine) also affects performance in ways you can observe but not directly control: it interprets code at first and then JIT-compiles “hot” functions — ones called repeatedly with stable argument shapes — into optimized machine code, and it runs garbage collection to reclaim memory from objects that are no longer reachable. Both matter for profiling: a function’s first few calls are slower than its two-thousandth call because V8 hasn’t optimized it yet, and a GC pause shows up as a mysterious latency spike if you don’t account for it.

Node.js exposes several ways to observe all of this: the node:perf_hooks module for fine-grained timing and event loop delay monitoring, V8’s built-in sampling CPU profiler (--prof and --cpu-prof), the Inspector protocol (--inspect) for live profiling in Chrome DevTools, and process.memoryUsage() plus heap snapshots for memory. None of these change your code’s behavior — they only observe it, so it is always safe to add them temporarily while you investigate an issue.

Syntax

Performance and profiling in Node.js is a toolbox rather than a single API. The core pieces:

Tool What it measures How you use it
performance.now() / .mark() / .measure() Wall-clock duration of a block of code Import from node:perf_hooks, mark start/end, read the entry from a PerformanceObserver
monitorEventLoopDelay() How long the event loop is delayed between iterations node:perf_hooks, returns a histogram you sample over time
performance.eventLoopUtilization() Fraction of time the loop spent doing work vs. idle node:perf_hooks, call it twice and diff the results
process.hrtime.bigint() Nanosecond-resolution timestamp for manual timing Global process object, no import needed
node --prof Statistical CPU profile of the whole run Run your script with the flag, then process the log with --prof-process
node --cpu-prof CPU profile written as a .cpuprofile file Load the file into Chrome DevTools’ Profiler tab as a flame chart
node --inspect Live debugging and profiling of a running process Connect via chrome://inspect or an editor’s debugger
process.memoryUsage() RSS, heap used/total, external memory Global process object
v8.writeHeapSnapshot() Full heap snapshot for finding leaks node:v8, load the file in DevTools’ Memory tab

Examples

Example 1: Timing code with perf_hooks

The most direct way to measure a block of code is performance.mark() and performance.measure(), paired with a PerformanceObserver that receives the result asynchronously:

import { performance, PerformanceObserver } from "node:perf_hooks";

const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
  }
  obs.disconnect();
});
obs.observe({ entryTypes: ["measure"] });

performance.mark("sort-start");
const numbers = Array.from({ length: 1_000_000 }, () => Math.random());
numbers.sort((a, b) => a - b);
performance.mark("sort-end");

performance.measure("sort-1M-numbers", "sort-start", "sort-end");
sort-1M-numbers: 96.31ms

The exact duration depends entirely on the machine running it, but the pattern is what matters: mark a start point, do the work, mark an end point, then measure between the two marks. The observer fires once the measure entry exists, so this works well for one-off diagnostics without cluttering your code with manual timers everywhere.

Example 2: Detecting event loop stalls

monitorEventLoopDelay() returns a histogram that tracks how much extra time passes between event loop iterations beyond what was expected — in other words, how “laggy” the process is right now:

import { monitorEventLoopDelay } from "node:perf_hooks";

const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();

setInterval(() => {
  console.log(
    `event loop delay - mean: ${(delay.mean / 1e6).toFixed(2)}ms, max: ${(delay.max / 1e6).toFixed(2)}ms`
  );
  delay.reset();
}, 2000);

function blockEventLoop(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy-wait on purpose to simulate a slow synchronous operation
  }
}

setTimeout(() => {
  console.log("blocking the event loop for 400ms...");
  blockEventLoop(400);
}, 1000);
blocking the event loop for 400ms...
event loop delay - mean: 38.15ms, max: 401.62ms
event loop delay - mean: 0.12ms, max: 0.87ms

The histogram values are in nanoseconds, so dividing by 1e6 converts them to milliseconds. Under normal conditions the mean and max stay near zero, since the loop is only ever idle for microseconds between callbacks. The moment the synchronous blockEventLoop call runs, the next reading’s max jumps to roughly the length of the block — this is exactly the signal you’d watch in production to catch a handler that’s silently freezing the process.

Example 3: Offloading CPU-bound work with worker_threads

Instead of just detecting a stall, you can prevent it by running expensive synchronous work on a separate thread with node:worker_threads, and communicating the result back with a message. Save the worker as its own file:

// worker.js
import { parentPort, workerData } from "node:worker_threads";

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData));
// main.js
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";

const workerPath = fileURLToPath(new URL("./worker.js", import.meta.url));

function fibInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(workerPath, { workerData: n });
    worker.once("message", resolve);
    worker.once("error", reject);
  });
}

let ticks = 0;
const heartbeat = setInterval(() => {
  ticks++;
  console.log(`heartbeat #${ticks}`);
}, 100);

const result = await fibInWorker(42);
console.log(`fib(42) = ${result}`);

clearInterval(heartbeat);
heartbeat #1
heartbeat #2
heartbeat #3
heartbeat #4
heartbeat #5
heartbeat #6
heartbeat #7
heartbeat #8
heartbeat #9
heartbeat #10
fib(42) = 267914296

Compare this to Example 2: the heartbeat keeps firing steadily every 100ms the entire time fib(42) is being computed, because the recursive computation runs on a separate OS thread inside the worker, not on the main thread. The main thread stays free to run its own timers and handle other events while it waits for the worker's message event. This is the standard fix once profiling shows a specific function is both CPU-heavy and blocking the loop.

How It Works Step by Step: CPU Profiling from the Command Line

For anything more complex than a single suspect function, you want a full profile of where the CPU actually spent its time. The V8 sampling profiler workflow goes through the following steps in order:

  1. You start the process with node --prof app.js. V8's built-in sampling profiler begins interrupting execution roughly a thousand times a second and recording the current call stack at each interruption.
  2. Each sample is appended to a binary tick log in the current directory, named something like isolate-0x<hex>-<pid>-v8.log. This adds very little overhead and does not change your program's behavior.
  3. You exercise the code path you care about — send it real or simulated traffic — then let the process exit normally, or stop it, which flushes the log to disk.
  4. You run node --prof-process against that log to convert the raw ticks into a human-readable report, grouped into JavaScript, C++, garbage collection, and shared library time, with each function annotated by its "self" and "total" tick percentages.
  5. You read the report top-down: the functions with the highest self percentage are where the CPU actually spent its time — that is where optimization effort pays off, not wherever you assumed the bottleneck was.
  6. For a visual flame chart instead of a text report, use node --cpu-prof app.js (or attach live to a running process with node --inspect) and load the resulting .cpuprofile file into Chrome DevTools' Profiler tab, which renders the same sampled call stacks as an interactive flame graph.
node --prof server.js
# ...exercise the server with real or simulated traffic, then stop it (Ctrl+C)...

ls isolate-*.log
node --prof-process isolate-0x1234-56789-v8.log > profile.txt

# or, for an interactive flame graph instead of a text report:
node --cpu-prof --cpu-prof-dir=./profiles --cpu-prof-name=run1.cpuprofile server.js
# open chrome://inspect in Chrome, click "Open dedicated DevTools for Node",
# then Profiler tab -> Load -> select profiles/run1.cpuprofile

# or attach live to a process that's already running:
node --inspect server.js
# Node prints a debugger URL; open chrome://inspect, click "inspect" under
# Remote Target, then use the Profiler and Memory tabs against the live process

Common Mistakes

Mistake 1: Blocking the event loop in a request handler

Using a synchronous API inside an HTTP handler blocks every concurrent request, not just the current one:

import http from "node:http";
import fs from "node:fs";

const server = http.createServer((req, res) => {
  // BAD: this blocks the event loop for every single request
  const data = fs.readFileSync("./large-report.csv", "utf8");
  res.writeHead(200, { "Content-Type": "text/csv" });
  res.end(data);
});

server.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});

Fix it by streaming the file instead of reading it fully into memory and blocking on the read. pipeline() from node:stream/promises also forwards stream errors and cleans up automatically, which manual .pipe() chains don't do:

import http from "node:http";
import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";

const server = http.createServer(async (req, res) => {
  try {
    res.writeHead(200, { "Content-Type": "text/csv" });
    await pipeline(createReadStream("./large-report.csv"), res);
  } catch (err) {
    console.error("failed to stream report:", err);
    if (!res.headersSent) {
      res.writeHead(500, { "Content-Type": "text/plain" });
    }
    res.end("Internal Server Error");
  }
});

server.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});

Mistake 2: Benchmarking a single, cold call

V8 hasn't optimized a function the first time it runs, so timing one call and treating that as "the" cost of the function is misleading:

function isPrime(n) {
  if (n < 2) return false;
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) return false;
  }
  return true;
}

const n = 999999937;

// Misleading: a single call includes parsing plus V8's unoptimized interpreter pass
const singleStart = process.hrtime.bigint();
isPrime(n);
const singleNs = process.hrtime.bigint() - singleStart;
console.log(`single call: ${singleNs}ns`);

// Better: warm up the JIT, then measure many iterations and average
for (let i = 0; i < 10_000; i++) isPrime(n);

const iterations = 100_000;
const warmStart = process.hrtime.bigint();
for (let i = 0; i < iterations; i++) {
  isPrime(n);
}
const warmNs = process.hrtime.bigint() - warmStart;
console.log(`average after warm-up: ${warmNs / BigInt(iterations)}ns per call`);
single call: 48200ns
average after warm-up: 210ns per call

The exact numbers vary by machine and V8 version, but the gap between the cold single call and the warmed-up average is consistently large. Always benchmark with realistic, repeated, warmed-up runs — a tool like tinybench automates this, but a manual loop like the one above works too.

Mistake 3: An unbounded cache that leaks memory

A cache that never evicts anything isn't a cache, it's a memory leak that just takes longer to notice:

const cache = new Map();

function getUserProfile(userId) {
  if (cache.has(userId)) return cache.get(userId);
  const profile = loadProfileFromDatabase(userId);
  cache.set(userId, profile); // never evicted - grows forever as new users are seen
  return profile;
}

Bound the cache size (or add a TTL) so memory stays flat under long-running, high-cardinality traffic:

const MAX_CACHE_SIZE = 1000;
const cache = new Map();

function getUserProfile(userId) {
  if (cache.has(userId)) return cache.get(userId);
  const profile = loadProfileFromDatabase(userId);
  if (cache.size >= MAX_CACHE_SIZE) {
    const oldestKey = cache.keys().next().value; // Maps preserve insertion order
    cache.delete(oldestKey);
  }
  cache.set(userId, profile);
  return profile;
}

If you suspect a leak like this in a running process, take two heap snapshots with v8.writeHeapSnapshot() (or the DevTools Memory tab over --inspect) minutes apart under steady traffic and compare retained object counts — a count that keeps growing with no ceiling points straight at the leaking structure.

Best Practices

  • Measure before optimizing — use profiler output to find the actual bottleneck rather than guessing based on what "feels" slow.
  • Keep CPU-heavy work off the main thread with worker_threads, a queue, or a separate service; the event loop should only ever do quick, non-blocking work.
  • Prefer streams (pipeline() from node:stream/promises) over reading whole files or responses into memory, especially for large or unbounded input.
  • Benchmark with warmed-up, repeated runs so you're measuring optimized steady-state performance, not V8's cold-start interpreter pass.
  • Watch monitorEventLoopDelay() or performance.eventLoopUtilization() in production to catch loop stalls before users report timeouts.
  • Use node --prof / --cpu-prof with --prof-process or Chrome DevTools for CPU profiling, and heap snapshots for memory leaks — don't rely on intuition alone.
  • Bound every cache, buffer, and in-memory collection that grows with user input; unbounded growth is the most common cause of slow memory leaks.
  • Avoid heavy synchronous logging or JSON serialization in hot paths; keep per-request work minimal and push expensive formatting to where it's actually needed.
  • Reach for purpose-built profiling tools like clinic or 0x (both installable via npm) when you need a flame graph without hand-rolling the --prof-process workflow.

Practice Exercises

  1. Write a script that performs the same expensive computation two ways: once as one long synchronous loop, and once broken into chunks scheduled with setImmediate between them. Use monitorEventLoopDelay() to measure and compare the maximum event loop delay each approach causes.
  2. Run node --prof against a script that sorts a large array inside a loop many times, then process the log with --prof-process. Find which function has the highest "self" percentage in the report.
  3. Build a small HTTP server with two routes that both return the contents of a large file — one using fs.readFileSync, the other using pipeline() with a read stream. Send several concurrent requests to each (for example with autocannon) and compare how responsive the server stays under load.

Summary

  • Node.js runs your JavaScript on one thread; synchronous CPU-bound work blocks every concurrent request, while async I/O does not.
  • node:perf_hooks gives you performance.mark()/.measure() for timing code and monitorEventLoopDelay() for detecting loop stalls.
  • node --prof plus --prof-process, --cpu-prof, and --inspect give you full CPU profiles, as text reports or interactive flame graphs.
  • Move CPU-heavy work to worker_threads so it doesn't stall the main thread's event loop.
  • Always benchmark warmed-up, repeated runs — a single cold call includes V8's unoptimized interpreter pass and misrepresents real performance.
  • Memory leaks are usually unbounded caches, listeners, or buffers — bound them, and confirm suspicions with heap snapshots.