Performance Profiling

Node.js Advanced — Performance Profiling

Node.js Advanced

Chapter 1 of 8  ·  Performance Profiling

Performance Profiling

The first rule of performance work: don't guess. A slow Node app is almost always slow in one specific place, and that place is almost never the one you'd guess. This chapter covers the tools that show you exactly where time is going — from built-in Node flags and the perf_hooks module, through Chrome DevTools CPU profiling, to specialist tools like Clinic.js. Once you can measure, you can fix.

The Profiling Toolkit

ToolWhat it showsWhen to reach for it
perf_hooks High-resolution timings for specific code paths Micro-benchmarks, measuring a known suspect function
--prof + --prof-process V8's built-in tick-based CPU profile as a text report Quick first look without opening a browser
Chrome DevTools (CPU profile) Flame graph of where CPU time was spent Finding the hot function in a running server
Chrome DevTools (heap snapshot) Objects in memory, what's retaining them Memory-related slowness, growing heap, leak investigation
Clinic.js (clinic flame) Interactive flame graph, async-aware Deep CPU profiling with minimal setup
Clinic.js (clinic doctor) Event-loop lag, CPU, memory, handles — all at once First diagnosis when you don't know where the problem is

perf_hooks — Measuring What You Already Suspect

The node:perf_hooks module gives you sub-millisecond timings without any external tools. Start here when you have a specific function or code path you want to benchmark.

const { performance, PerformanceObserver } = require('node:perf_hooks'); // ── Simple timing ──────────────────────────────────────────────────────────── const start = performance.now(); doExpensiveWork(); const elapsed = performance.now() - start; console.log(`Took ${elapsed.toFixed(3)} ms`); // ── Named marks and measures (shows up in DevTools timeline) ───────────────── performance.mark('parse-start'); const data = JSON.parse(hugeJsonString); performance.mark('parse-end'); performance.measure('json-parse', 'parse-start', 'parse-end'); // ── PerformanceObserver — listen for measure entries ───────────────────────── const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(`${entry.name}: ${entry.duration.toFixed(3)} ms`); } }); obs.observe({ entryTypes: ['measure'] });
// ── timerify — wrap any function to auto-measure every call ───────────────── const { performance } = require('node:perf_hooks'); function slowSort(arr) { return [...arr].sort((a, b) => a - b); } const timedSort = performance.timerify(slowSort); const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(`slowSort: ${entry.duration.toFixed(3)} ms`); } }); obs.observe({ entryTypes: ['function'] }); timedSort([5, 3, 8, 1]); // logs duration automatically

--prof: V8's Built-in Tick Profiler

Running Node with --prof samples the call stack every millisecond and writes a log file. --prof-process turns that log into a human-readable report — no browser needed.

# 1. Run your app with profiling enabled node --prof server.js # (exercise the app — make some requests, run the slow path) # Then Ctrl+C. A file named isolate-0x...log is written. # 2. Process the log into a readable report node --prof-process isolate-*.log > profile.txt # 3. Open profile.txt — the top section shows the hottest functions: # # [JavaScript]: # ticks total nonlib name # 823 41.2% 58.4% /app/utils.js:47:processRecord # 201 10.1% 14.3% /app/db.js:112:buildQuery # # The function at the top is where the CPU is spending its time.

Chrome DevTools — CPU Flame Graph

The flame graph is the most readable view of CPU usage: the wider a bar, the more time was spent in that function. Functions at the top of the stack are the hot ones to optimise — but read upward to understand why they're being called.

# Start Node with the inspector enabled node --inspect server.js # or, to pause at the first line: node --inspect-brk server.js # Then in Chrome: navigate to chrome://inspect # Click "inspect" under your Node process. # # In DevTools: # 1. Go to the "Profiler" tab # 2. Click "Start" — exercise your app (make requests, run the slow path) # 3. Click "Stop" # 4. Switch to "Chart" view to see the flame graph
Reading a flame graph handleRequest ← wide = lots of time here (but look UPWARD for the real cost) ├─ parseBody ← called from handleRequest │ └─ JSON.parse ← very wide: this is the actual bottleneck ├─ validateInput └─ queryDatabase ← narrow = fast, not the problem X axis = time (wider = more CPU time) Y axis = call stack depth (bottom = entry point, top = the function actually running)

Measuring Event-Loop Lag

Blocking the event loop is the most common Node performance problem. Any synchronous work that takes more than a few milliseconds delays every queued callback — including incoming HTTP requests. You can measure this directly.

// Event-loop lag monitor — logs how long the loop is being blocked function monitorEventLoopLag(intervalMs = 1000) { let last = process.hrtime.bigint(); setInterval(() => { const now = process.hrtime.bigint(); const lagMs = Number(now - last - BigInt(intervalMs * 1_000_000)) / 1_000_000; last = now; if (lagMs > 10) { console.warn(`Event-loop lag: ${lagMs.toFixed(1)} ms`); } }, intervalMs).unref(); } monitorEventLoopLag(500); // Node 18.2+ has this built in: const { monitorEventLoopDelay } = require('node:perf_hooks'); const h = monitorEventLoopDelay({ resolution: 20 }); h.enable(); setInterval(() => { console.log({ mean: (h.mean / 1e6).toFixed(2) + ' ms', p99: (h.percentile(99) / 1e6).toFixed(2) + ' ms', max: (h.max / 1e6).toFixed(2) + ' ms', }); h.reset(); }, 5000).unref();

Clinic.js — The Power Tools

Clinic.js wraps your app and produces rich interactive HTML reports. clinic doctor is the best starting point for an unknown performance problem; clinic flame gives the most detailed CPU breakdown.

# Install once, globally npm install -g clinic autocannon # clinic doctor — health overview (CPU, memory, event-loop lag, handles) clinic doctor -- node server.js # In a second terminal: send load autocannon -c 100 -d 10 http://localhost:3000/ # clinic flame — interactive flame graph (best for CPU bottlenecks) clinic flame -- node server.js # clinic bubbleprof — async activity map (best for I/O / async bottlenecks) clinic bubbleprof -- node server.js # Each command opens an HTML report in your browser automatically.

💡 autocannon — Load Testing from the Terminal

You need real traffic to profile a server. autocannon is a fast HTTP benchmarking tool that saturates your server so the profiler has something to measure:

autocannon -c 50 -d 20 http://localhost:3000/api/users

Flags: -c = concurrent connections, -d = duration in seconds. The output shows requests/sec, latency percentiles, and throughput — useful both for load testing and for generating profiling data.

Heap Snapshots — What's in Memory

A heap snapshot is a point-in-time photograph of every object in memory: what it is, how big it is, and what's holding a reference to it. Take two snapshots — before and after a suspected leak — and compare them to see what grew.

// Take a heap snapshot programmatically (writes an .heapsnapshot file) const v8 = require('node:v8'); const fs = require('node:fs'); function takeHeapSnapshot(label = 'snapshot') { const filename = `heap-${label}-${Date.now()}.heapsnapshot`; const stream = v8.writeHeapSnapshot(filename); console.log(`Heap snapshot written: ${stream}`); return filename; } // Trigger on SIGUSR2 so you can snapshot a running server without restarting process.on('SIGUSR2', () => takeHeapSnapshot('on-demand')); # Send the signal from your terminal: # kill -USR2 <pid>
// Load the .heapsnapshot file in Chrome DevTools: // chrome://inspect → Memory tab → Load → select file // // Most useful views: // Summary — group by constructor (spot unexpected huge arrays) // Comparison — diff two snapshots (shows what was allocated between them) // Containment — follow the reference chain to see what's holding an object // // Red objects in Comparison = allocated since snapshot 1 and not freed = suspects

Common Bottlenecks and Their Fixes

Synchronous JSON on large payloads

JSON.parse and JSON.stringify are synchronous and block the event loop for large documents. Stream the JSON with stream-json, or offload to a Worker Thread (Chapter 4).

Synchronous file or crypto operations

fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync — any *Sync function blocks. Always use the async/promise version or Worker Threads for CPU-bound crypto.

Inefficient regular expressions

Catastrophic backtracking (ReDoS) can freeze the event loop for seconds. Test suspicious regexes with safe-regex or the vuln-regex-detector package before deploying.

Too many microtasks

Flooding process.nextTick or Promise.resolve() in a tight loop starves I/O callbacks. Use setImmediate to yield control back to the event loop between batches.

Missing database indexes

A slow query that takes 200 ms on a small dataset takes 20 s on production data. Profile with EXPLAIN ANALYZE in MySQL/Postgres before blaming Node.

Unbounded in-memory collections

Arrays and Maps that grow without being pruned eventually cause GC pressure and slow everything down. Set a maximum size and evict old entries (LRU pattern — see Chapter 5).

⚠️ Profile in Production-Like Conditions

Development data is too small. A route that processes 10 records is fast everywhere. Run profiling against a dataset that matches production volume — the bottleneck often only appears at scale.

NODE_ENV matters. Many frameworks disable optimisations in development (extra logging, unminified assets, stack traces). Profile with NODE_ENV=production to see the real numbers.

Don't profile with --inspect under load. The V8 inspector adds overhead. Use --prof or Clinic.js for load-test profiling; use DevTools only for short targeted sessions.

Coding Challenges

Challenge 1 — Benchmark Suite

Write a bench(name, fn, iterations = 10_000) function that runs fn iterations times, measures the total and per-iteration time using performance.now(), and prints a summary table showing: name, iterations, total ms, mean µs per iteration, and ops/sec. Then use it to compare three ways of concatenating 100 strings: += in a loop, Array.join, and template literals in a reduce. Print the results ranked fastest to slowest.

View sample solution ↗

Challenge 2 — Event-Loop Lag Detector

Write an EventLoopMonitor class with start() and stop() methods. While running, it samples event-loop lag every 100 ms and tracks: current lag, mean lag, maximum lag, and a count of how many samples exceeded a configurable warnThreshold (default 10 ms). The report() method returns these stats. Demonstrate it by running the monitor alongside a function that intentionally blocks the event loop for 50 ms every second, and verify the monitor detects the spikes.

View sample solution ↗

Challenge 3 — Profiled Middleware

Write an Express middleware factory profileMiddleware(options) that wraps each request with performance.mark/measure calls, recording: total request duration, time spent in downstream middleware/route handler, and the route path + method. It should use a PerformanceObserver to collect all measurements and expose a GET /metrics endpoint that returns a JSON summary of the slowest 10 routes (by mean duration), including call count and p95 latency for each.

View sample solution ↗