Node.js NotesRohit’s interview study guide
Chapter 10

Performance & Memory

Measure first — event-loop delay, CPU profiles, memory leaks, garbage collection and weak references.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What to measure

Performance work goes wrong when you start with a fix ("add Redis", "use cluster") instead of a measurement. A Node service can be slow for very different reasons, and each has a different cure. Measure, name the bottleneck, change one thing, measure again.

A useful mental model is a restaurant with one chef (the main JavaScript thread) and many waiters (libuv, the OS, the database). A slow evening can mean the chef is chopping too much (CPU-bound JavaScript), the chef is stuck on one huge order and everyone else waits (event-loop blocking), the suppliers are late (slow database or downstream API), or the kitchen is full of dirty dishes (memory and garbage-collection pressure). More chefs, faster suppliers and a bigger dishwasher are different purchases. You find out which one you need by watching the kitchen, not by guessing.

The numbers that matter:

  • Latency percentiles — p50 is the typical request; p95 and p99 are the slow tail that users and timeouts actually feel. Averages hide the tail.
  • Throughput — requests or jobs completed per second at an acceptable latency.
  • Event-loop delay and utilisation — how late callbacks run, and how busy the main thread is (next topic).
  • CPU — per process, and for the host or container (a CPU-throttled container looks like a slow app).
  • Memory and GC — heapUsed after GC over time, RSS, GC pause times.
  • Downstream timing — how long each database query or HTTP call takes, measured from your side.

Here's why averages lie. createHistogram() from node:perf_hooks is the same histogram type Node uses for event-loop delay, and you can record your own values into it:

percentiles.cjs
const { createHistogram } = require('node:perf_hooks');

const h = createHistogram(); // records integers; we'll record milliseconds
// 1,000 requests: most are quick, 2% hit a slow path (a cold cache, a lock, a GC pause)
for (let i = 0; i < 1_000; i++) h.record(i % 50 === 0 ? 2_000 : 40 + (i % 7));

console.log({
  count: h.count,
  mean: h.mean.toFixed(1),
  p50: h.percentile(50),
  p95: h.percentile(95),
  p99: h.percentile(99),
  max: h.max,
});
// { count: 1000, mean: '82.1', p50: 43, p95: 46, p99: 2000, max: 2000 }
What’s happening
  1. The loop records 1,000 fake latencies. Every 50th request (i % 50 === 0, so 20 of them) takes 2,000 ms; the other 980 take between 40 and 46 ms.
  2. h.mean is 82.1 ms. That looks like a healthy service — but no request actually took 82 ms. The mean is dragged up by 20 very slow requests and describes nobody.
  3. p50 is 43: half the requests finished in 43 ms or less. p95 is 46: 95% were still fast.
  4. p99 is 2000: the slowest 1% (and here 2%) take two full seconds. If a page makes 10 API calls, the chance that at least one of them lands in that 2% is about 18% — so a "2% problem" hits almost one page view in five.
  5. This is why dashboards and SLOs use p95/p99, and why a fix that improves the mean but not p99 may not help users at all.
AdvancedReference: bottlenecks and typical fixes

From my notes, the quick answer to "how can you improve the performance of a Node.js application?" was: caching (Redis), clustering for multiple cores, asynchronous code so the event loop isn't blocked, optimised database queries with indexes, and response compression. Those are all real techniques, but each one fixes a different bottleneck class:

BottleneckSymptomTypical fix
CPU-bound JavaScripthigh CPU, hot functions in a profilebetter algorithm, fewer allocations, a worker pool
Event-loop blockinghigh event-loop delay, every request slow at onceremove sync calls from hot paths, split or offload work
Downstream I/Olow CPU, long waits on DB/HTTPindexes, query plans, connection pooling, concurrency, caching
Memory / GCheapUsed climbing after GC, long GC pausesfix retention, bound caches, stream instead of buffering
Serialisation and loggingCPU spent in JSON.stringify, logginglog less, don't serialise huge objects
Whole-server capacityone process at 100% CPU under loadmore processes or pods (cluster, Kubernetes)
AdvancedCorrecting my notes: what each tool measures

#Measuring event-loop delay

Every callback in Node — a request handler, a timer, a promise continuation — runs on one thread. If something holds that thread, every other callback waits in line. Event-loop delay measures how long they wait: schedule a timer for "now + 10 ms" and see how late it actually fires. Idle, it's on time; blocked, it's late by however long the blocking took.

Node gives you two built-in gauges in node:perf_hooks:

  • monitorEventLoopDelay({ resolution }) — samples the loop every resolution ms and records the timings in a histogram (min, max, mean, percentile(n), all in nanoseconds).
  • performance.eventLoopUtilization() (ELU) — the fraction of time the loop was busy running code rather than idle waiting for events. 0 is idle, 1 is never idle. (Node 25.2+ also exports it directly as eventLoopUtilization from node:perf_hooks.)

Think of delay as "how long the queue at the counter is" and ELU as "what fraction of the time the cashier is busy". You want both.

eld.cjs
const { monitorEventLoopDelay, performance } = require('node:perf_hooks');

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
let elu = performance.eventLoopUtilization();

function report(label) {
  const now = performance.eventLoopUtilization(elu); // delta since last call
  elu = performance.eventLoopUtilization();
  console.log(label, {
    p50: (h.percentile(50) / 1e6).toFixed(1) + ' ms',
    p99: (h.percentile(99) / 1e6).toFixed(1) + ' ms',
    max: (h.max / 1e6).toFixed(1) + ' ms',
    elu: now.utilization.toFixed(2),
  });
  h.reset();
}

function blockFor(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {} // busy-wait: the thread never yields
}

setTimeout(() => {
  report('idle   ');
  const t = setInterval(() => blockFor(200), 250); // 200 ms of every 250 ms is blocked
  setTimeout(() => {
    clearInterval(t);
    report('blocked');
    setTimeout(() => { report('after  '); h.disable(); }, 1000);
  }, 1000);
}, 1000);

// idle    { p50: '12.0 ms', p99: '12.0 ms', max: '12.0 ms', elu: '0.00' }
// blocked { p50: '12.0 ms', p99: '206.4 ms', max: '206.4 ms', elu: '0.60' }
// after   { p50: '12.0 ms', p99: '12.0 ms', max: '12.0 ms', elu: '0.00' }
What’s happening
  1. monitorEventLoopDelay({ resolution: 10 }) creates a histogram and enable() starts a native timer that wakes every 10 ms and records how long it has been since the last sample. performance.eventLoopUtilization() takes a starting snapshot of busy/idle time.
  2. Idle second: nothing runs, so each sample arrives on schedule. On Node 26 the recorded value is the whole time between samples, so idle reads about the resolution — here 12.0 ms (10 ms plus macOS timer slack), not 0. Read the histogram as "how far above the resolution". ELU is 0.00: the thread was idle almost all the time.
  3. Blocked second: setInterval runs blockFor(200) every 250 ms. During each busy-wait the sampling timer can't fire, so the next sample measures a ~206 ms gap — that's the p99 and max. Most samples still land between blocks, so p50 stays 12.0 ms: look at the tail, not the median.
  4. ELU for that window is 0.60: three 200 ms blocks fell inside the 1-second window, so the thread was busy 600 ms out of 1,000. Under real load, ELU near 1 means the process has no spare capacity.
  5. h.reset() clears the histogram after each report, so each line describes only its own window. After the interval is cleared, delay and ELU drop straight back — the loop recovers as soon as the blocking stops.
  6. In a real service you'd export these (p99 delay, max delay, ELU) every 10–30 seconds to your metrics system. A spike tells you that something blocked the loop; a CPU profile tells you what.
AdvancedReading the measured delay numbers

These exact numbers vary by machine and OS, but the shape — a flat baseline, a tail that jumps to the blocking duration, ELU tracking the busy fraction — is the same everywhere. The event-loop chapter shows the same effect from a request's point of view in Blocking the event loop.

AdvancedDiagnosis: finding what held the thread

#Profiling CPU

When the main thread is busy, guessing which function is slow is usually wrong. A CPU profiler samples the call stack at a fixed interval (every 1 ms by default) and counts how often each function was on top. A function that appears in 90% of samples is using about 90% of the CPU time. That's self time; total time also includes the functions it called.

Node has the V8 profiler built in. node --cpu-prof app.cjs writes a .cpuprofile file when the process exits. You can open it in Chrome DevTools (Performance panel, or the dedicated Node DevTools from chrome://inspect) to see a flame chart, or in VS Code. node --inspect lets you attach DevTools to a running process and record a profile on demand.

Here's a "report" with one obvious hot spot:

hot.cjs
// A deliberately slow "report" with one obvious hot spot.
function slugify(s) {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); // cheap
}

function isPrime(n) {
  // naive trial division: the hot spot
  for (let d = 2; d < n; d++) if (n % d === 0) return false;
  return n > 1;
}

function buildReport(rows) {
  return rows.map((r) => ({ slug: slugify(r.name), primes: countPrimes(r.limit) }));
}

function countPrimes(limit) {
  let c = 0;
  for (let n = 2; n < limit; n++) if (isPrime(n)) c++;
  return c;
}

const rows = Array.from({ length: 40 }, (_, i) => ({ name: `Row ${i}`, limit: 20_000 }));
console.time('report');
const out = buildReport(rows);
console.timeEnd('report');
console.log(out[0]);
// report: 576.517ms
// { slug: 'row-0', primes: 2262 }
AdvancedWriting a profile with --cpu-prof
Shell
node --cpu-prof --cpu-prof-dir=prof hot.cjs
ls prof
# CPU.20261007.144834.67727.0.001.cpuprofile   (7.5 KB)
AdvancedSummarising a .cpuprofile by self time

A .cpuprofile is JSON: a tree of nodes (each with a callFrame — function name, file, line), a samples array saying which node was on top at each tick, and timeDeltas between ticks. That's enough to compute self time per function without DevTools:

top.cjs
// Summarise a .cpuprofile: self time per function, sorted.
const fs = require('node:fs');
const file = process.argv[2];
const p = JSON.parse(fs.readFileSync(file, 'utf8'));
const byId = new Map(p.nodes.map((n) => [n.id, n]));
const dt = new Map(); // node id -> microseconds
p.samples.forEach((id, i) => dt.set(id, (dt.get(id) || 0) + (p.timeDeltas[i] || 0)));
const self = new Map();
for (const [id, us] of dt) {
  const { functionName, url, lineNumber } = byId.get(id).callFrame;
  const key = `${functionName || '(anonymous)'} ${url ? url.split('/').pop() + ':' + (lineNumber + 1) : ''}`.trim();
  self.set(key, (self.get(key) || 0) + us);
}
const total = [...self.values()].reduce((a, b) => a + b, 0);
console.log(`samples: ${p.samples.length}, total: ${(total / 1000).toFixed(0)} ms`);
for (const [k, us] of [...self].sort((a, b) => b[1] - a[1]).slice(0, 6))
  console.log(`${(us / 1000).toFixed(1).padStart(8)} ms  ${((100 * us) / total).toFixed(1).padStart(5)}%  ${k}`);

// $ node top.cjs prof/*.cpuprofile
// samples: 388, total: 587 ms
//    576.8 ms   98.3%  isPrime hot.cjs:6
//      7.8 ms    1.3%  (idle)
//      1.5 ms    0.3%  _write writable:452
//      0.7 ms    0.1%  countPrimes hot.cjs:16
What’s happening
  1. --cpu-prof starts V8's sampling profiler before hot.cjs runs and writes the profile when the process exits; --cpu-prof-dir chooses the folder. The file name contains the date, time and PID.
  2. top.cjs adds up timeDeltas per sampled node — the time that node was on top of the stack — then groups nodes by function name and line.
  3. The answer is unambiguous: isPrime (line 6) has 98.3% of the self time. buildReport, slugify and countPrimes barely appear, even though buildReport is "the slow report" — it's slow only because of what it calls. A flame chart shows the same thing as a wide isPrime bar under countPrimes under buildReport.
  4. (idle) is time the thread was waiting, and _write writable is console.log writing to stdout. On a server, a large (idle) share during a slow request means the time is going to waiting (I/O), not CPU — a profile also tells you when not to optimise JavaScript.
  5. The fix is in the hot function: trial division only needs to go up to √n. Changing the loop to d * d <= n took the report from about 600 ms to 12–15 ms on this machine (same 2262 result), and the new profile shows only 13 samples.
AdvancedProfiling one production instance

#Avoiding synchronous work on hot paths

From my notes: "Avoid synchronous code: synchronous code blocks the event loop, preventing the application from handling other requests. Always use asynchronous methods for I/O (fs.readFile() vs fs.readFileSync())." That's right, and the reason is worth saying precisely. A *Sync call does the whole job on the main thread and returns only when it's finished. The async version hands the job to the OS or to libuv's thread pool and returns immediately, so the main thread can run other callbacks meanwhile (see Call stack, libuv and the thread pool).

The classic hot-path mistakes are fs.readFileSync/writeFileSync inside a route, crypto.pbkdf2Sync/scryptSync for password hashing, zlib.gzipSync, child_process.execSync, a giant JSON.parse/JSON.stringify, and synchronous logging to a slow destination. Here are synchronous and asynchronous password hashing next to a heartbeat timer that should tick every 50 ms:

sync-vs-async.cjs
const crypto = require('node:crypto');
const { performance } = require('node:perf_hooks');

// A "heartbeat" that should fire every 50 ms. Its lateness = how blocked the loop was.
let last = performance.now(), worstLate = 0;
const beat = setInterval(() => {
  const now = performance.now();
  worstLate = Math.max(worstLate, now - last - 50);
  last = now;
}, 50);

const hashSync = () => crypto.pbkdf2Sync('secret', 'salt', 1_000_000, 32, 'sha256');
const hashAsync = () => new Promise((res, rej) =>
  crypto.pbkdf2('secret', 'salt', 1_000_000, 32, 'sha256', (e, k) => (e ? rej(e) : res(k))));

async function trial(label, fn) {
  await new Promise((r) => setTimeout(r, 120)); // settle
  worstLate = 0;
  const t0 = performance.now();
  await fn();
  await new Promise((r) => setTimeout(r, 60));   // let a beat land after the work
  console.log(`${label}: work took ${(performance.now() - t0 - 60).toFixed(0)} ms, heartbeat worst lateness ${worstLate.toFixed(0)} ms`);
}

(async () => {
  await trial('4 x pbkdf2Sync ', async () => { for (let i = 0; i < 4; i++) hashSync(); });
  await trial('4 x pbkdf2     ', () => Promise.all([hashAsync(), hashAsync(), hashAsync(), hashAsync()]));
  clearInterval(beat);
})();

// 4 x pbkdf2Sync : work took 294 ms, heartbeat worst lateness 262 ms
// 4 x pbkdf2     : work took 92 ms, heartbeat worst lateness 1 ms
What’s happening
  1. The heartbeat interval records how late each tick is compared with the 50 ms it asked for. worstLate is reset at the start of each trial, so each line shows the worst stall during that trial.
  2. Sync trial: four pbkdf2Sync calls run one after another on the main thread, about 73 ms each. For ~290 ms nothing else can run, so the heartbeat that was due during that time fires ~262 ms late. In a server, every other request would have waited the same amount.
  3. Async trial: pbkdf2 (no Sync) queues the hashing on libuv's thread pool and returns at once. The four hashes run in parallel on four pool threads (the default pool size is 4), so all four finish in ~92 ms — about the time of one.
  4. Meanwhile the main thread is free and the heartbeat is never more than 1 ms late. Same CPU work, same results; the difference is which thread does it.
  5. If you made it 8 async hashes, the pool's 4 threads would take two rounds (~180 ms); raising UV_THREADPOOL_SIZE helps only if you have spare cores. The thread pool is for libuv's built-in jobs — your own JavaScript loops need worker threads.
AdvancedException: sync calls at startup
AdvancedAlternative: cooperative yielding

Cooperative yielding (splitting a long loop into chunks with setImmediate) is the other option when the work must stay on the main thread — see Yielding to the event loop with setImmediate.

#How garbage collection decides what to free

JavaScript has no free(). V8's garbage collector decides what to free using one rule: an object is kept if it's reachable — if there is a chain of references to it from a root. Roots are things that are alive by definition: the global object, the variables of functions currently on the stack, module-level variables of loaded modules, active handles (timers, sockets) and their callbacks. Everything reachable from a root survives; everything else is garbage, whether or not you "meant" to keep it.

Picture the heap as balloons tied to strings, and the roots as hands. A balloon floats away (is collected) only when no hand holds any string leading to it. Setting a variable to null cuts one string; if another object — a cache, a listener list, a closure — still holds a string, the balloon stays.

reach.cjs
const mb = () => (process.memoryUsage().heapUsed / 2 ** 20).toFixed(1) + ' MB';

const sessions = new Map(); // long-lived: lives as long as the module

function login(id) {
  const user = { id, history: new Array(1_000_000).fill(id) }; // ~8 MB
  sessions.set(id, user);
  return user;
}

let current = login('u1');
global.gc();
console.log('after login:            ', mb());

current = null; // drop one reference...
global.gc();
console.log('current = null:         ', mb()); // still reachable via sessions

sessions.delete('u1'); // ...and the last one
global.gc();
console.log('sessions.delete("u1"):  ', mb());

// $ node --expose-gc reach.cjs
// after login:             11.0 MB
// current = null:          11.2 MB
// sessions.delete("u1"):   3.6 MB
What’s happening
  1. --expose-gc makes global.gc() available, which forces a full collection. Only use it for experiments like this — it lets us read heapUsed after GC, so the numbers show what's retained rather than what's merely not yet collected.
  2. login('u1') creates a user whose history array holds a million 8-byte slots (~7.6 MB). Two references now point at it: the local user (returned and stored in current) and the sessions Map entry. Heap: 11.0 MB.
  3. current = null cuts one string. The object is still reachable — root (module scope) → sessions → entry 'u1' → user — so GC keeps it: still 11.2 MB. This is the most common misunderstanding about null: it doesn't free anything, it only removes one path.
  4. sessions.delete('u1') removes the last path. On the next collection the user and its array are unreachable and get freed: 3.6 MB (the ~7.5 MB drop is the array).
  5. Note the timing: memory is freed when the GC runs, not at the moment the last reference disappears. Without global.gc(), V8 would collect it whenever it next decided to.
AdvancedGenerations: why most garbage is cheap

V8 bets that most objects die young (request-scoped objects, temporary arrays). So the heap is split:

  • Young generation (new space) — small (a few MB to tens of MB). New objects go here. It's collected often by a fast Scavenge: copy the few survivors out and throw the rest away wholesale. Cost is proportional to the survivors, not the garbage.
  • Old generation (old space) — objects that survived a couple of scavenges are promoted here. It's collected less often by Mark-Compact: mark everything reachable from the roots, sweep the rest, compact to reduce fragmentation. V8 does most of the marking incrementally and concurrently on helper threads, but there are still pauses on the main thread.

You can watch this with --trace-gc, which prints one line per collection:

Shell
node --trace-gc gctrace.cjs
# [70168:0x78940c000]   7 ms: Scavenge 4.4 (5.1) -> 4.0 (5.9) MB, pooled: 0.0 MB, 0.24 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
# [70168:0x78940c000]  23 ms: Scavenge 37.8 (46.8) -> 34.2 (47.1) MB, pooled: 0.0 MB, 3.99 / 0.00 ms ... allocation failure;
# [70168:0x78940c000]  37 ms: Incremental Mark-Compact 74.4 (89.5) -> 59.1 (73.3) MB, pooled: 0.8 MB, 3.57 / 0.00 ms ... finalize incremental marking via task; GC in old space requested

Read each line as: when (ms since start), which collector, heap used (heap committed) before -> after, and the main-thread pause in ms. "allocation failure" just means new space filled up — the normal trigger for a scavenge. gctrace.cjs builds a big array of surviving objects while also creating short-lived ones; the survivors are why the "after" numbers keep climbing until a Mark-Compact runs.

What this means for performance, from my notes on "Garbage Collection Implications for High-Performance Applications": reducing memory churn (creating and discarding lots of objects) reduces how often GC runs, and keeping long-lived data small reduces how much Mark-Compact has to walk. In practice: don't allocate in tight loops when you can reuse, avoid building huge intermediate arrays (map().filter().map() over a million items allocates three arrays), and stream large data instead of holding it (see Optimising memory use).

AdvancedThe heap limit: --max-old-space-size

The old generation has a size limit. When it's reached and GC can't free enough, the process dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory.

Shell
node -e "console.log(v8.getHeapStatistics().heap_size_limit / 2**20)"
# 4192        (this machine: 64 GB RAM, Node 26 default)
node --max-old-space-size=512 -e "console.log(v8.getHeapStatistics().heap_size_limit / 2**20)"
# 608         (512 MB old space + young generation and other spaces)

V8 picks the default from available memory, so it differs between machines. In containers, set it explicitly and leave headroom: the limit covers only the JS heap, while Buffers, native modules and thread stacks live outside it and still count against the container's memory limit. A common rule is a heap limit around 70–80% of the container's memory. (Node 26 also accepts --max-old-space-size-percentage=75%, which sizes the heap from available memory.)

#Common causes of memory leaks

A memory leak in a garbage-collected language is memory you no longer need that is still reachable. Nothing is "lost" — your program is holding on to it, usually through something long-lived. Every leak fits the same shape:

long-lived root → container → reference → obsolete data

My notes list the four classic causes — globals, event listeners, closures and uncleared timers — and the ChatGPT guide adds the one that's most common in real services: unbounded collections and caches. Here's the pattern that bites most often in servers: a per-request listener added to a long-lived emitter and never removed.

leak.cjs
const { EventEmitter } = require('node:events');

const config = new EventEmitter(); // long-lived, like an app-wide "config changed" bus
config.setMaxListeners(0);         // silence the warning so we can watch memory instead

const FIX = process.argv[2] === 'fixed';

function handleRequest(i) {
  const ctx = { id: i, rows: new Array(1_250).fill(i) }; // ~10 KB of per-request data
  const onChange = () => console.log('request', ctx.id, 'sees new config');
  config.on('change', onChange);       // subscribe for the life of the request
  // ... do the work, send the response ...
  if (FIX) config.off('change', onChange); // unsubscribe when the request ends
}

const mb = () => (process.memoryUsage().heapUsed / 2 ** 20).toFixed(1);
let i = 0;
for (let round = 1; round <= 5; round++) {
  for (let n = 0; n < 2_000; n++) handleRequest(i++);
  global.gc();
  console.log(`after ${String(i).padStart(5)} requests: heapUsed ${mb()} MB, listeners ${config.listenerCount('change')}`);
}

// $ node --expose-gc leak.cjs
// after  2000 requests: heapUsed 22.9 MB, listeners 2000
// after  4000 requests: heapUsed 42.5 MB, listeners 4000
// after  6000 requests: heapUsed 62.0 MB, listeners 6000
// after  8000 requests: heapUsed 81.4 MB, listeners 8000
// after 10000 requests: heapUsed 100.9 MB, listeners 10000
//
// $ node --expose-gc leak.cjs fixed
// after  2000 requests: heapUsed 3.5 MB, listeners 0
// after  4000 requests: heapUsed 3.6 MB, listeners 0
// after  6000 requests: heapUsed 3.6 MB, listeners 0
// after  8000 requests: heapUsed 3.6 MB, listeners 0
// after 10000 requests: heapUsed 3.6 MB, listeners 0
What’s happening
  1. config is module-level, so it lives for the whole process — it's the long-lived root. setMaxListeners(0) turns off the MaxListenersExceededWarning so we can see the effect in memory rather than in a warning.
  2. Each handleRequest creates ctx (~10 KB) and an arrow function onChange that closes over ctx. Registering it means config's listener array now holds onChange, which holds ctx: root → emitter → listener array → closure → ctx.
  3. Leaky run: the request "ends" but nobody removes the listener. After a forced GC, every ctx is still reachable, so heapUsed climbs by ~19.5 MB per 2,000 requests (≈10 KB each), and listenerCount matches the number of requests exactly. That's a leak: memory grows linearly with traffic, and eventually the process hits its heap limit. (As a bonus bug, each config change would now call 10,000 stale handlers.)
  4. Fixed run: config.off('change', onChange) removes the same function reference at the end of the request. Now nothing reachable points at ctx, and GC frees it: heap stays flat at 3.6 MB after 10,000 requests.
  5. The fix needs the same function object — config.off('change', () => …) with a new arrow would remove nothing. In modern code, events.on(emitter, name, { signal }), emitter.once or an AbortSignal tied to the request's lifetime make the cleanup harder to forget (see on, once, off and removeListener).
AdvancedFour more leak shapes

The other causes, one by one:

Four more leak shapes (illustrative)
// 1. Unbounded module-level collection: grows forever with traffic
const history = [];
function recordVisit(req) { history.push({ url: req.url, at: Date.now(), headers: req.headers }); }
// Fix: cap it (ring buffer / LRU), give entries a TTL, or send it to a log/DB instead of memory.

// 2. Accidental global (sloppy-mode CommonJS only)
function parse() { result = { big: new Array(1e6) }; } // no let/const: creates globalThis.result
// In strict mode or an ES module this line throws ReferenceError: result is not defined.

// 3. A timer that is never cleared keeps its callback, and everything the callback uses, alive
function startPolling(session) {
  const id = setInterval(() => refresh(session), 1_000);
  return () => clearInterval(id); // the owner must call this when the session ends
}

// 4. A closure that captures more than it needs
function makeHandler(bigReport) {
  const title = bigReport.title;
  return () => title;          // captures only the string...
  // return () => bigReport.title;  // ...this would keep the whole bigReport alive for as long as the handler lives
}
What’s happening
  1. Unbounded collections are the most common real leak. history is module-level, so every entry is reachable forever. This is also how "simple in-memory caches" leak: a Map with no size limit or expiry. Storing an expiresAt field isn't enough — something must actually delete expired entries (see Cache-aside with a TTL).
  2. Accidental globals: I ran it — in a sloppy-mode .cjs file, assigning to an undeclared name really does create globalThis.result, which lives forever. In an ES module (or with 'use strict') the same line throws ReferenceError: result is not defined, so the bug can't happen silently. Modern code is mostly strict, so this one is rarer today than old articles suggest.
  3. Timers: an active setInterval is a root. Its callback and everything the callback closes over (session) stay alive until clearInterval. A one-shot setTimeout releases its callback after it fires; an interval never does. Creating an interval per connection without clearing it on close is a classic leak.
  4. Closures: a closure keeps alive the variables it uses. Copying out the small value you need (title) instead of referencing the big object lets the big object be collected. (V8 decides what a closure retains per scope, so if any closure from the same function uses bigReport, it's kept for all of them — another reason to extract what you need early.)
AdvancedCorrecting my notes: clearing closures
AdvancedNot a leak: large caches and sawtooth graphs

#Finding a memory leak

From my notes: use heap snapshots in Chrome DevTools (compare snapshots to see if memory keeps growing), --inspect to attach DevTools to the process, and --trace-gc to see whether heap size keeps growing despite regular GC runs. That's the right toolkit; here's the workflow that ties it together.

  1. Confirm the trend. Graph process.memoryUsage() over time under steady load. heapUsed is the live JS heap; heapTotal is what V8 has reserved; rss is the whole process's resident memory; external is memory owned by C++ objects tied to JS (Buffers), and arrayBuffers (included in external) is ArrayBuffer and Buffer storage. Don't add them up — they overlap.
  2. Take snapshots at intervals. Snapshot A after warm-up, run the suspicious workload, snapshot B, run it again, snapshot C.
  3. Compare. In DevTools' Memory panel, open B and choose "Comparison" against A. Sort by "# Delta" or "Size Delta". The class whose count grows by the same amount each round is your suspect.
  4. Follow the retaining path. Select one instance; the "Retainers" pane shows the chain from a root to it — e.g. system / Context → config → _events → change → [1234] → onChange → ctx. That chain is the bug.
  5. Fix the ownership (remove the listener, bound the cache) and repeat the same experiment to prove the count stays flat.

You can take snapshots from code with v8.writeHeapSnapshot(). A .heapsnapshot is JSON in the format DevTools reads, so you can even count objects yourself:

snap.cjs
const v8 = require('node:v8');
const fs = require('node:fs');

class RequestContext {
  constructor(id) { this.id = id; this.rows = new Array(100).fill(id); }
}
const auditLog = []; // the bug: nothing ever removes entries

function handle(id) { auditLog.push(new RequestContext(id)); }

let id = 0;
const files = [];
for (const label of ['A', 'B', 'C']) {
  for (let n = 0; n < 5_000; n++) handle(id++);
  files.push(v8.writeHeapSnapshot(`snap-${label}.heapsnapshot`)); // forces a GC first
}
console.log(files, files.map((f) => (fs.statSync(f).size / 2 ** 20).toFixed(1) + ' MB'));
// [ 'snap-A.heapsnapshot', 'snap-B.heapsnapshot', 'snap-C.heapsnapshot' ] [ '5.6 MB', '6.6 MB', '7.5 MB' ]
Advancedcount.cjs: counting objects in snapshots
count.cjs
// Count objects by constructor name in .heapsnapshot files (the format DevTools reads).
const fs = require('node:fs');
const want = process.argv[2];
for (const file of process.argv.slice(3)) {
  const s = JSON.parse(fs.readFileSync(file, 'utf8'));
  const f = s.snapshot.meta.node_fields;                // ["type","name","id","self_size",...]
  const types = s.snapshot.meta.node_types[0];          // ["hidden","array","string","object",...]
  const T = f.indexOf('type'), N = f.indexOf('name'), S = f.indexOf('self_size');
  let count = 0, bytes = 0;
  for (let i = 0; i < s.nodes.length; i += f.length) {
    if (types[s.nodes[i + T]] === 'object' && s.strings[s.nodes[i + N]] === want) {
      count++; bytes += s.nodes[i + S];
    }
  }
  console.log(`${file}: ${count} ${want} objects, ${bytes} bytes shallow`);
}
// $ node count.cjs RequestContext snap-A.heapsnapshot snap-B.heapsnapshot snap-C.heapsnapshot
// snap-A.heapsnapshot: 5000 RequestContext objects, 200000 bytes shallow
// snap-B.heapsnapshot: 10000 RequestContext objects, 400000 bytes shallow
// snap-C.heapsnapshot: 15000 RequestContext objects, 600000 bytes shallow
What’s happening
  1. snap.cjs simulates 5,000 requests per round, each pushing a RequestContext into auditLog, which nothing ever trims — the leak.
  2. v8.writeHeapSnapshot(name) writes the whole heap to disk and returns the file name. V8 runs a full GC before taking a snapshot, so the snapshot contains only reachable objects — I checked: 1,000 objects created and dropped before a snapshot didn't appear in it at all, while 3 that were kept did.
  3. A snapshot stores objects as a flat nodes array of integers, node_fields.length entries per object (type, name index, id, self size, …), with names looked up in strings. count.cjs walks it and counts objects whose constructor name is RequestContext.
  4. The counts go 5,000 → 10,000 → 15,000: every round adds exactly 5,000 objects that survive GC. That linear growth matching the workload is the signature of a leak. DevTools' Comparison view shows the same thing as "# Delta +5000", and its Retainers pane would show auditLog holding them.
  5. "Shallow" size is the object itself (40 bytes each here); "retained" size, which DevTools also shows, includes what would be freed with it — here mostly the 100-slot rows array. Sort by retained size to find the objects that matter most.
AdvancedCost: snapshot pauses and memory

#WeakMap, WeakSet and WeakRef

Everything in the previous topics comes back to one fact: a normal reference keeps its target alive. Weak references don't. A weak reference lets you find an object if it's still alive without being the reason it stays alive. The basics of WeakMap and WeakSet are on the JavaScript site at WeakMap and WeakSet; here the focus is the use case my notes flagged as important — caching without memory leaks.

  • WeakMap — keys must be objects (or non-registered symbols). The map holds its keys weakly: when nothing else references a key, the entry (and the value, if nothing else holds it) is collected. It's not iterable and has no size, because its contents can change at any GC.
  • WeakSet — the same idea for membership ("have I seen this object?").
  • WeakRef — a single weak pointer: new WeakRef(obj), then ref.deref() returns the object or undefined if it's been collected.

The mental model: a WeakMap is a set of sticky notes attached to objects you don't own. When the object is thrown away, the note goes with it. A Map is a filing cabinet that keeps a copy of everything you ever put in it.

weak.cjs
const mb = () => (process.memoryUsage().heapUsed / 2 ** 20).toFixed(1) + ' MB';

// Derived data cached per request object. Which cache keeps the request alive?
const strong = new Map();
const weak = new WeakMap();

function expensiveSummary(req) {
  return { rows: new Array(100_000).fill(req.id) }; // ~0.8 MB per request
}

function run(cache, label) {
  for (let i = 0; i < 50; i++) {
    const req = { id: i };              // the request object: dies when "the request ends"
    cache.set(req, expensiveSummary(req));
  }
  global.gc();
  console.log(label.padEnd(8), mb());
}

global.gc();
console.log('start   ', mb());
run(weak, 'WeakMap');
run(strong, 'Map');
console.log('Map size', strong.size);

// $ node --expose-gc weak.cjs
// start    3.4 MB
// WeakMap  4.4 MB
// Map      41.8 MB
// Map size 50
What’s happening
  1. Both caches map a request object to an ~0.8 MB summary computed from it. In a real app this could be "parsed body per request", "permissions per user object" or "metadata per socket".
  2. WeakMap run: each req is a local variable; after its loop iteration, the only thing pointing at it is the WeakMap key — which doesn't count. At global.gc() all 50 keys are unreachable, so all 50 entries and their summaries are collected: 4.4 MB, barely above the 3.4 MB start.
  3. Map run: the Map holds its keys strongly. Each req is reachable through the Map, so its summary is too: 41.8 MB after GC (50 × ~0.8 MB), and strong.size is 50. Every request ever seen is retained — the unbounded-cache leak from earlier.
  4. The WeakMap version needs no cleanup code at all: the cache entry's lifetime is tied to the key's lifetime automatically. That's exactly when a WeakMap is the right tool — the key is an object owned by someone else, and the value is data about it.
AdvancedString-keyed caches with WeakRef

A WeakMap only works when the cache key is an object. For a cache keyed by strings (IDs, URLs) holding big values, you can hold the values weakly with WeakRef, so the GC may drop them under memory pressure:

weakref.cjs
const { setImmediate: tick } = require('node:timers/promises');

class WeakCache {
  #refs = new Map(); // key -> WeakRef(value)
  #registry = new FinalizationRegistry((key) => {
    // runs some time after a value is collected: drop the dead entry
    if (!this.#refs.get(key)?.deref()) this.#refs.delete(key);
  });
  get(key) { return this.#refs.get(key)?.deref(); }
  set(key, value) {
    this.#refs.set(key, new WeakRef(value));
    this.#registry.register(value, key);
  }
  get size() { return this.#refs.size; }
}

async function main() {
  const cache = new WeakCache();
  let report = { title: 'Q3', rows: new Array(1_000_000).fill(0) };
  cache.set('q3', report);

  global.gc();
  console.log('same job, still referenced:', cache.get('q3')?.title); // Q3

  report = null;                  // no strong references left
  global.gc();
  console.log('same job, after gc:        ', cache.get('q3')?.title); // still Q3!

  await tick();                   // let the current job end
  global.gc();
  console.log('next job, after gc:        ', cache.get('q3')?.title); // undefined
  console.log('entries before cleanup:    ', cache.size);
  await tick();                   // give the finalizer a chance to run
  console.log('entries after cleanup:     ', cache.size);
}
main();

// $ node --expose-gc weakref.cjs
// same job, still referenced: Q3
// same job, after gc:         Q3
// next job, after gc:         undefined
// entries before cleanup:     1
// entries after cleanup:      0
What’s happening
  1. WeakCache stores WeakRefs in an ordinary Map keyed by string. get calls deref(), which returns the value if it's alive or undefined if it's been collected. The FinalizationRegistry removes the dead WeakRef wrapper from the Map later (next topic), so the Map itself doesn't fill up with empty entries.
  2. While report still points at the object, it's strongly reachable and get('q3') returns it: Q3.
  3. report = null removes the last strong reference, and we force a GC — yet get('q3') still returns Q3. This is a deliberate rule in the spec: an object that was created as a WeakRef target or deref()ed during the current synchronous job is kept alive until that job ends, so code like if (ref.deref()) ref.deref().x can't see it vanish between two calls.
  4. await tick() (a setImmediate) ends the current job. The next global.gc() can now collect the report, and get('q3') returns undefined — a cache miss, so the caller would recompute or refetch.
  5. cache.size is still 1 right after the GC: the Map holds an empty WeakRef. After one more turn, the registry's callback has run and deleted it: 0.
  6. Trade-off: when the GC drops values is up to V8, so hit rates are unpredictable — under no memory pressure it may keep everything, under pressure it may drop everything at once. For a predictable cache use an LRU with a size limit and TTL (LRU cache); use a WeakRef cache only as an "extra layer" for big values you'd be happy to recompute.
AdvancedCorrecting my notes: when weak collections fit

#FinalizationRegistry

Advanced

FinalizationRegistry lets you ask: "after this object has been garbage-collected, call me back with this value." You register(target, heldValue); some time after target is collected, your callback runs with heldValue. The held value must not be the target itself (that would keep it alive — V8 throws target and holdings must not be same), so it's usually a key, an ID or a handle number.

It exists for cleaning up side data tied to an object's life: removing dead entries from a WeakRef cache (as above), or releasing a native resource as a safety net if the user forgot to call close(). The important word is some time after — maybe much later, maybe never.

finreg.cjs
const registry = new FinalizationRegistry((heldValue) => {
  console.log('finalized:', heldValue);
});

function openTempFile(name) {
  const handle = { name, buffer: new Array(10_000).fill(0) };
  registry.register(handle, `cleanup ${name}`); // held value: must NOT be the handle itself
  return handle;
}

let a = openTempFile('a.tmp');
let b = openTempFile('b.tmp');
const token = {};
let c = openTempFile('c.tmp');
registry.register(c, 'cleanup c.tmp (second registration)', token);
registry.unregister(token); // cancels only the registration made with this token

setImmediate(() => {
  a = b = c = null;
  if (globalThis.gc) globalThis.gc();
  console.log('gc() returned');
  setTimeout(() => console.log('exiting'), 50);
});

// $ node --expose-gc finreg.cjs        (order of the "finalized" lines varied between runs)
// gc() returned
// finalized: cleanup c.tmp
// finalized: cleanup b.tmp
// finalized: cleanup a.tmp
// exiting
//
// $ node finreg.cjs
// gc() returned
// exiting
What’s happening
  1. Three handles are registered with held values 'cleanup a.tmp', 'cleanup b.tmp', 'cleanup c.tmp'. c gets a second registration with an unregister token; registry.unregister(token) cancels just that one, which is how you say "the user called close() properly, no need for the safety net".
  2. Inside setImmediate (a new job, so nothing is kept alive by the current one), all three variables are cleared and gc() runs. The callbacks don't run inside gc(): gc() returned prints first, and the finalizers run afterwards as separate tasks.
  3. With --expose-gc, all three ran — but in different orders on different runs (c, b, a four times, b, a, c once in five runs). Never depend on finalization order.
  4. Without --expose-gc, the program allocates so little that V8 never needs to collect, so no callback ran at all before exit. The spec allows this: finalizers can be delayed indefinitely, and pending ones are simply dropped when the process exits.
  5. The conclusion: a FinalizationRegistry callback is a best-effort extra, never the primary cleanup. Close files, release locks and flush data explicitly (try/finally, close(), using with Symbol.dispose). Use the registry to tidy caches or to log "you forgot to close this" warnings.

#Optimising memory use

Most memory wins in Node come from three habits: don't hold what you can stream, store numbers as numbers, and bound everything that grows. My notes' "Optimizing memory" points — use Map/Set appropriately, manage memory efficiently, use Buffers efficiently — all fit under these.

First, streaming. Reading a whole file to hash it versus streaming it through the hash:

hashfile.cjs
const fs = require('node:fs');
const { createHash } = require('node:crypto');
const { pipeline } = require('node:stream/promises');

const mb = (n) => (n / 2 ** 20).toFixed(0) + ' MB';
let peakRss = 0, peakExternal = 0;
const sampler = setInterval(() => {
  const m = process.memoryUsage();
  peakRss = Math.max(peakRss, m.rss);
  peakExternal = Math.max(peakExternal, m.external);
}, 5);

async function whole(file) {
  const data = await fs.promises.readFile(file); // the whole file in one Buffer
  return createHash('sha256').update(data).digest('hex');
}

async function streamed(file) {
  const hash = createHash('sha256');
  await pipeline(fs.createReadStream(file), hash); // 64 KiB chunks
  return hash.digest('hex');
}

const mode = process.argv[2];
(mode === 'stream' ? streamed : whole)('big.log').then((hex) => {
  clearInterval(sampler);
  const m = process.memoryUsage();
  peakRss = Math.max(peakRss, m.rss); peakExternal = Math.max(peakExternal, m.external);
  console.log(mode.padEnd(6), hex.slice(0, 12), 'peak rss', mb(peakRss), 'peak external', mb(peakExternal));
});

// big.log is a 200 MB file
// $ node hashfile.cjs whole
// whole  50062bf0d2f6 peak rss 248 MB peak external 202 MB
// $ node hashfile.cjs stream
// stream 50062bf0d2f6 peak rss 84 MB peak external 28 MB
What’s happening
  1. A 5 ms interval samples rss (whole process) and external (Buffer memory, outside the JS heap) to catch the peaks.
  2. whole: readFile allocates one 200 MB Buffer and fills it, so external peaks at 202 MB and RSS at 248 MB. Memory scales with the file: a 5 GB file would need 5 GB, and Buffers have a maximum size, so very large files fail outright.
  3. stream: createReadStream reads 64 KiB at a time and pipeline feeds each chunk into the hash, which is itself a writable stream. Only a few chunks exist at once, plus chunks GC hasn't reclaimed yet: external peaks around 28 MB, RSS 84 MB — and those numbers stay the same for a 5 GB file.
  4. Both print the same hash (50062bf0d2f6…): same result, a fraction of the memory. pipeline also handles backpressure and errors for you (see Backpressure).
  5. The same applies to HTTP: pipe a file or database cursor to res instead of building the whole response body in memory first.
AdvancedData layout: typed arrays vs objects

Second, data layout. A JavaScript object per item is flexible but heavy. For large numeric data, typed arrays store raw numbers back to back:

layout.cjs
const N = 1_000_000;
const used = () => {
  global.gc();
  const m = process.memoryUsage();
  return m.heapUsed + m.arrayBuffers; // JS heap + ArrayBuffer backing stores
};

const before = used();
let keep;
if (process.argv[2] === 'objects') {
  keep = [];
  for (let i = 0; i < N; i++) keep.push({ x: i * 0.5, y: i * 0.25 });
} else {
  keep = { xs: new Float64Array(N), ys: new Float64Array(N) };
  for (let i = 0; i < N; i++) { keep.xs[i] = i * 0.5; keep.ys[i] = i * 0.25; }
}
const delta = used() - before;
// Reading `keep` here keeps it alive through the measurement (see the gotcha below).
console.log(process.argv[2].padEnd(7), (delta / 2 ** 20).toFixed(1), 'MB', keep.length ?? keep.xs.length);

// $ node --expose-gc layout.cjs objects
// objects 78.6 MB 1000000
// $ node --expose-gc layout.cjs typed
// typed   15.3 MB 1000000
What’s happening
  1. Each mode stores one million (x, y) points and measures the JS heap plus ArrayBuffer memory after a GC, before and after.
  2. objects: one million { x, y } objects take 78.6 MB — roughly 80 bytes per point. Each object has a header (its hidden class pointer and property/element pointers), two field slots, and, because the values are non-integer doubles, V8 stores each number in its own small heap box; the array adds an 8-byte slot per element. (Exact sizes depend on the V8 version and build.)
  3. typed: two Float64Arrays store exactly 8 bytes per number with no per-item overhead: 2 × 8 MB = 15.3 MiB. That's about 5× smaller, and the data lives outside the JS heap, so the GC doesn't have to trace a million objects.
  4. Typed arrays aren't a general replacement — you lose named fields and flexible shapes. They're the right choice for large numeric datasets: time series, coordinates, matrices, image data, or columnar data you'll pass to a worker (they can be transferred without copying).
AdvancedMeasurement trap: early-freed variables
AdvancedOther memory habits at scale

Other habits that matter at scale:

  • Map and Set for dynamic keys. My notes point out that Map doesn't suffer from prototype pollution and has predictable insert/lookup performance; it also handles frequent deletes better than delete obj[key], which can push an object into slow "dictionary mode". Set deduplicates without building a lookup object.
  • Bound every collection that grows with traffic: caches, queues, per-user maps, retry lists. Give each one a maximum size and an eviction rule.
  • Reuse instead of re-allocating in hot loops — preallocated buffers, or an object pool for expensive objects (see Object pooling). Pools only pay off for genuinely expensive objects; for small objects V8's young-generation GC is already very cheap.
  • Use Buffers for bytes, strings for text. Converting binary data to strings and back costs CPU, copies and can corrupt non-text bytes (Why use Buffers instead of strings).
  • Don't log huge objects. Serialising a 5 MB response to log it costs CPU and memory on every request.

#Speeding up a slow route, step by step

Here's how the pieces fit together in a realistic case. A /dashboard route reads the user, their orders and recommendations one after another, computes a summary synchronously, and logs the whole response. It's slow. The order of operations matters as much as the techniques: measure → fix the biggest measured cost → re-measure, repeat.

dashboard.cjs
const { setTimeout: sleep } = require('node:timers/promises');
const { performance } = require('node:perf_hooks');

// Fake downstream calls with fixed latencies.
const getUser = async (id) => (await sleep(120), { id, name: 'Rohit' });
const getOrders = async (id) => (await sleep(150), Array.from({ length: 2_000 }, (_, i) => ({ i, total: i % 97 })));
const getRecs = async (id) => (await sleep(100), ['a', 'b', 'c']);

function summarise(orders) {             // synchronous CPU work
  let s = 0;
  for (let k = 0; k < 20_000; k++) for (const o of orders) s += Math.sqrt(o.total * k);
  return Math.round(s);
}

async function timed(label, fn) {
  const t0 = performance.now();
  const v = await fn();
  console.log(`  ${label.padEnd(10)} ${(performance.now() - t0).toFixed(0).padStart(4)} ms`);
  return v;
}

async function dashboardV1(id) {          // as written: one await after another
  const user = await timed('user', () => getUser(id));
  const orders = await timed('orders', () => getOrders(id));
  const recs = await timed('recs', () => getRecs(id));
  const score = await timed('summarise', () => summarise(orders));
  return { user, score, recs };
}

async function dashboardV2(id) {          // independent reads started together
  const [user, orders, recs] = await timed('all reads', () =>
    Promise.all([getUser(id), getOrders(id), getRecs(id)]));
  const score = await timed('summarise', () => summarise(orders));
  return { user, score, recs };
}

(async () => {
  for (const [name, fn] of [['v1', dashboardV1], ['v2', dashboardV2]]) {
    console.log(name);
    const t0 = performance.now();
    await fn(7);
    console.log(`  ${'TOTAL'.padEnd(10)} ${(performance.now() - t0).toFixed(0).padStart(4)} ms`);
  }
})();

// v1
//   user        120 ms
//   orders      151 ms
//   recs        100 ms
//   summarise    35 ms
//   TOTAL       408 ms
// v2
//   all reads   151 ms
//   summarise    33 ms
//   TOTAL       185 ms
What’s happening
  1. Measure where the time goes. timed() wraps each step with performance.now(). In a real service, this is what tracing spans (OpenTelemetry) or per-query timings give you. v1's breakdown: 120 + 151 + 100 ms of waiting, then 35 ms of CPU, total 408 ms.
  2. Run independent I/O concurrently. The three reads don't depend on each other, but v1 awaits each before starting the next, so their latencies add up. v2 starts all three with Promise.all, so the total wait is the slowest one: 151 ms instead of 371 ms. Total drops to 185 ms without touching the database (see Promise.all and running work concurrently).
  3. Fix the slowest downstream call next. orders at 150 ms is now the floor. In real life that's an EXPLAIN on the query, a missing index, fetching 2,000 rows when the page shows 20, or a connection pool that's too small. This usually beats any JavaScript optimisation.
  4. Look at the CPU step under load, not just its duration. 35 ms sounds small, but it's 35 ms during which this process can't run any other request. At 30 concurrent dashboard requests, the last one waits about a second just for its turn. First profile and improve the algorithm; if it's still heavy, move it to a worker pool or precompute it.
  5. Cache repeatable reads — recommendations may be fine 60 seconds stale — with a TTL, a size bound and a plan for invalidation (Cache-aside with a TTL). Stream large responses instead of building them in memory, and stop logging the whole response body.
  6. Re-measure under the same load and compare p95/p99, throughput, CPU, event-loop delay and memory. Keep a change only if the numbers improved. Adding pods increases capacity, but it doesn't fix a route that blocks every process it runs on.
Which number tells you the event loop is blocked?

A service shows: average latency 60 ms, p99 latency 2.1 s, CPU 35%, event-loop delay p99 1.9 s, ELU 0.4. Is the problem slow downstream calls, or something blocking the event loop?

Show answer

Something is blocking the event loop.

What’s happening
  1. Downstream slowness makes requests wait, but waiting doesn't delay unrelated callbacks — the loop stays responsive and event-loop delay stays near its baseline.
  2. Here the event-loop delay p99 is 1.9 s: the sampling timer itself was occasionally almost 2 s late. Only code running on the main thread (or a starved CPU) can make a timer that late.
  3. The latency p99 (2.1 s) is close to the loop-delay p99 (1.9 s), which fits: any request that arrived during the stall waited for it.
  4. Average CPU (35%) and ELU (0.4) look moderate because the blocking is occasional — a long synchronous task every so often, not constant load. Averages hide it, just like the latency mean (60 ms) does.
  5. Next step: take a CPU profile while the stalls happen and look for one long synchronous function (a huge JSON.parse, a sync crypto call, a pathological regex).

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.