Skip to solution
hardLow-Level Design

What are memory leaks in Node.js and how do you detect them?

950 views
01

Understand the problem

Question presented to candidate: "Your team's monitoring shows a Node process's memory growing steadily over days, never dropping, even during low-traffic periods. Where would you actually look first, and what specific number would you check?"

What a strong answer should cover:

  • A memory leak in a garbage-collected language like JavaScript is not "the GC failing" — V8's garbage collector correctly reclaims memory with no remaining reachable references. A leak is memory the application itself is still holding a live reference to, unintentionally, that will never be released as a result — a growing array, cache, or closure nobody ever clears.
  • 📌 A precise, verifiable distinction: process.memoryUsage()'s heapUsed tracks the JS object heap specifically. Buffers and ArrayBuffers are allocated outside that heap, tracked instead under external/arrayBuffers — verified directly: retaining 50MB of Buffers barely moved heapUsed (4.0MB → 4.9MB) while external/arrayBuffers grew by over 30x (to 54.3MB/52.6MB). Monitoring heapUsed alone would completely miss this real, concrete leak.
  • Common real-world leak sources: an ever-growing array or Map used as a cache with no eviction policy; an event listener registered repeatedly without ever being removed (each new listener retains its own closure); a closure capturing a large object unintentionally, kept alive by something still referencing that closure; a timer (setInterval) that is never cleared, itself retaining whatever its callback closes over.
  • Detection tools, from lightest to heaviest: watching process.memoryUsage() over time (cheap, coarse, and — verified above — must check the right field, not just heapUsed); a heap snapshot comparison (covered in its own dedicated question) taken at two points in time, diffed to see what object types grew; a dedicated profiler (Chrome DevTools' memory tab attached via --inspect, or clinic.js/similar tools) for a detailed retainer-path analysis pinpointing exactly what is holding a reference.
  • A precise answer distinguishes a genuine leak (memory that will never be released, growing without bound over the process's lifetime) from ordinary, expected memory usage growth under load (more concurrent requests legitimately using more memory, which should stabilize or shrink again once load drops) — the "never drops, even at low traffic" detail in the prompt is exactly what marks it as the former.
  • Forcing a garbage-collection pass (--expose-gc, calling global.gc()) is a real diagnostic technique for confirming whether memory is genuinely leaked (unreleased even after a forced full GC pass) versus simply not yet collected — though a precise answer notes real GC behavior can still leave some memory only partially reclaimed after one pass, not a perfectly clean before/after split.

Clarifying questions expected:

  • "Is memory growing under sustained load and then stabilizing/dropping, or growing indefinitely regardless of traffic?" — only the latter is a genuine leak.
  • "Have Buffers/external memory specifically been ruled out, or has only heapUsed been checked?" — verified above as a real, easy-to-miss distinction.

Code / implementation expected: Yes — the real, measured heapUsed vs. external/arrayBuffers split for a Buffer-based leak is the concrete, convincing deliverable, not a description of "memory can leak."

memorydebuggingv8
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Node.js interviews — assumes basic garbage-collection familiarity. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every memory number below came from **actually running the a

Solution ready — 2 min read

Classified // press E to declassify

04

Read the code

Retaining 50MB of Buffers, measured across heapUsed vs external/arrayBuffers, then released with a forced GC pass
// node --expose-gc script.js
const leaks = [];
function fmt(m) {
  return {
    heapUsed: (m.heapUsed / 1e6).toFixed(1) + "MB",
    external: (m.external / 1e6).toFixed(1) + "MB",
    arrayBuffers: (m.arrayBuffers / 1e6).toFixed(1) + "MB",
  };
}

console.log("before:", fmt(process.memoryUsage()));
// before: { heapUsed: '4.0MB', external: '1.6MB', arrayBuffers: '0.1MB' }

for (let i = 0; i < 50; i++) leaks.push(Buffer.alloc(1024 * 1024)); // 50MB retained
console.log("after 50MB retained:", fmt(process.memoryUsage()));
// after 50MB retained: { heapUsed: '4.9MB', external: '54.3MB', arrayBuffers: '52.6MB' }
// heapUsed barely moved — external/arrayBuffers grew over 30x. This is the leak
// a heapUsed-only monitor would completely miss.

leaks.length = 0; // release every reference
global.gc();
console.log("after release + forced gc:", fmt(process.memoryUsage()));
// after release + forced gc: { arrayBuffers: '47.3MB' }
// real, substantial drop — but not a perfectly clean return to baseline in one pass
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 123 of 152 decoded in the Node.js track. One more won't hurt.

Back to track