Memory leaks

Detecting and measuring

How to prove a leak instead of guessing — health-route baselines, the Chrome DevTools snapshot workflow, production-safe Node flags, the navigate-20-times client test, and a memory watermark test for CI.

"I think it leaks" is not an engineering statement. The skill an interviewer is testing here is whether you can turn a vague report — pods restarting at 3am, the dashboard getting sluggish by lunchtime — into a measurement with a baseline, a load, and a number you can put in a pull request. The whole method rests on one idea: a healthy heap grows and then plateaus; a leak is a plateau that never arrives.

Know

  • Server symptoms: RSS and heap climb under steady traffic and never return after a GC; pods restart with OOM; p99 latency degrades as GC pauses get longer; MaxListenersExceededWarning appears in logs. Rising memory alone is not a leak — caches fill and V8 is lazy about collecting — so always compare after a forced collection.
  • A baseline beats a graph. Expose process.memoryUsage() on an internal route and record rss, heapUsed, external and arrayBuffers. heapUsed growing points at JS objects; external/arrayBuffers growing without heapUsed points at Buffers, streams and sockets — a completely different hunt.
  • Snapshots are diffed, never read. One heap snapshot tells you almost nothing. Two, taken around a load test with a forced GC in between, and compared in DevTools' Comparison view sorted by size delta, tell you exactly which constructor grew.
  • Names to look for in the delta: Array, Map, Object, (closure), ReactiveEffect, ComputedRefImpl, EffectScope, and your own component names. Then open Retainers — the question is never "what is big" but "who is holding it".
  • Production-safe tooling exists. --heapsnapshot-signal=SIGUSR2 plus kill -USR2 <pid> writes a snapshot from a running process without an inspector port; --heap-prof records allocation sampling; --trace-gc shows whether collections are actually reclaiming anything. --max-old-space-size is a mitigation, never a fix.
  • process.getActiveResourcesInfo() lists live handles and requests by type — the fastest way to confirm the "one setInterval per request" leak, because Timeout entries pile up visibly.
  • Client symptoms are counted, not felt. DevTools' Performance Monitor gives live JS heap size, DOM node count and event listener count; all three should return to baseline after navigating away and collecting garbage. If listeners or DOM nodes ratchet upwards over 20 navigations, you have a leak regardless of what the heap number does.
  • Automate the thing you just did by hand. A Playwright loop sampling performance.memory.usedJSHeapSize, or memlab running a scenario, turns "I checked" into a test that fails in CI.

How it works

The health route

server/routes/_internal/memory.get.ts
export default defineEventHandler((event) => {
  // never expose this publicly: it is a fingerprint of your runtime
  if (getHeader(event, 'x-internal-token') !== process.env.INTERNAL_TOKEN) {
    throw createError({ statusCode: 404, statusMessage: 'Not Found' })
  }

  const m = process.memoryUsage()
  return {
    rss: m.rss,                     // total resident set: JS heap + native + code
    heapUsed: m.heapUsed,           // live JS objects — the leak signal for JS leaks
    heapTotal: m.heapTotal,
    external: m.external,           // C++ objects bound to JS (Buffers, streams)
    arrayBuffers: m.arrayBuffers,   // subset of `external`: ArrayBuffers and Buffers
    resources: process.getActiveResourcesInfo(), // ['Timeout', 'Timeout', 'TCPSocketWrap', …]
    uptime: process.uptime(),
  }
})

The Chrome DevTools workflow on the server

# 1. run the built server with an inspector, open chrome://inspect → Memory
node --inspect .output/server/index.mjs

# 2. warm up so JIT, module caches and lazy imports are done growing
npx autocannon -c 5 -d 5 http://localhost:3000/

# 3. take snapshot A, then load test — vary the URLs if you suspect a cache key
npx autocannon -c 20 -d 30 http://localhost:3000/

# 4. click "Collect garbage" (the bin icon), then take snapshot B
# 5. switch the snapshot view to "Comparison", sort by "Size Delta", open Retainers

Steps 4 and 5 are where people go wrong. Skipping the forced GC means you are looking at garbage that simply has not been collected yet, and reading snapshot B on its own means you are looking at a big application rather than a growing one.

For production, no inspector port is needed:

# start with the signal handler armed
node --heapsnapshot-signal=SIGUSR2 .output/server/index.mjs

# later, from the same container: writes Heap.<date>.heapsnapshot into cwd
kill -USR2 $(pgrep -f 'server/index.mjs')

# allocation sampling instead of snapshots (lighter, shows where bytes are born)
node --heap-prof --heap-prof-interval=262144 .output/server/index.mjs

# is GC reclaiming anything at all?
node --trace-gc .output/server/index.mjs 2>&1 | tail -f

Clinic.js (clinic doctor -- node .output/server/index.mjs, clinic heapprofiler) wraps the same signals with a guided diagnosis and is a good first pass when you do not yet know whether you are looking at a memory problem, an event-loop problem or a downstream one.

The client test

Do it by hand once so you know what the signal looks like: open the suspect page, DevTools → Memory, take a snapshot, navigate away and back 20 times, click "Collect garbage", take a second snapshot, and switch to Comparison. Filter the class list by Detached to find DOM nodes that are unreachable from the document but still referenced by JavaScript, and compare counts of your component names and ReactiveEffect. Twenty navigations with a clean page produce a delta near zero; a leak produces a count that matches the navigation count almost exactly, which is itself the diagnosis.

Then automate it:

test/client-memory.spec.ts
import { expect, test } from '@nuxt/test-utils/playwright'

// Chromium only, and launch with --enable-precise-memory-info for real numbers
test('the reports page does not grow the heap over 20 navigations', async ({ page, goto }) => {
  await goto('/reports', { waitUntil: 'hydration' })

  const sample = () => page.evaluate(() => {
    // @ts-expect-error non-standard, Chrome only
    return performance.memory.usedJSHeapSize as number
  })

  for (let i = 0; i < 5; i++) { await page.goto('/'); await page.goto('/reports') } // warm up
  const before = await sample()

  for (let i = 0; i < 20; i++) { await page.goto('/'); await page.goto('/reports') }
  const after = await sample()

  // generous threshold: this catches "grows 2 MB per visit", not micro-regressions
  expect(after - before).toBeLessThan(8 * 1024 * 1024)
})

For something stricter, memlab (memlab run --scenario …) drives the same navigation pattern with CDP heap snapshots and reports the retainer traces of leaked objects, so it tells you which object leaked rather than only that the heap grew.

The CI memory watermark test

The server equivalent, and the one worth shipping with a layer. Build the fixture, start it with --expose-gc, load it for 30 seconds, force a collection through a probe route that only exists when an env var is set, and assert the growth is below a threshold you calibrated on a green build.

test/fixtures/basic/server/routes/__mem.get.ts
export default defineEventHandler(() => {
  // the probe only exists when the test harness asks for it
  if (process.env.NUXT_MEMORY_PROBE !== '1') throw createError({ statusCode: 404 })

  globalThis.gc?.()                       // requires the process to run with --expose-gc
  const { heapUsed, rss, external } = process.memoryUsage()
  return { heapUsed, rss, external, resources: process.getActiveResourcesInfo() }
})
test/memory-watermark.test.ts
import { spawn } from 'node:child_process'
import { setTimeout as sleep } from 'node:timers/promises'
import autocannon from 'autocannon'
import { afterAll, beforeAll, expect, it } from 'vitest'

const PORT = 3123
const base = `http://localhost:${PORT}`
let server: ReturnType<typeof spawn>

const probe = () => fetch(`${base}/__mem`).then(r => r.json() as Promise<{ heapUsed: number }>)

beforeAll(async () => {
  // CI runs `nuxt build test/fixtures/basic` before this file
  server = spawn('node', ['--expose-gc', 'test/fixtures/basic/.output/server/index.mjs'], {
    env: { ...process.env, PORT: String(PORT), NUXT_MEMORY_PROBE: '1' },
    stdio: 'inherit',
  })
  for (let i = 0; i < 60; i++) {
    try { await fetch(base); break }
    catch { await sleep(500) }
  }
}, 180_000)

afterAll(() => { server?.kill() })

it('heapUsed stays below the watermark under sustained load', async () => {
  await autocannon({ url: base, connections: 5, duration: 5 })   // warm up JIT and caches
  const before = await probe()

  await autocannon({ url: base, connections: 20, duration: 30 })
  await sleep(1_000)
  const after = await probe()

  const grewMb = (after.heapUsed - before.heapUsed) / 1024 / 1024
  console.log(`heapUsed grew ${grewMb.toFixed(1)} MB over 30 s`)
  expect(grewMb).toBeLessThan(20)
}, 180_000)
Gotcha· Memory that grows is not automatically a leak

V8 only collects when it feels pressure, caches are meant to fill, and a freshly booted server legitimately gains tens of megabytes as JIT tiers up and lazy chunks load. Every claim of a leak therefore needs the same two controls: a warm-up before the first measurement and a forced GC before the last one. Without them you will spend a day chasing the shape of a healthy heap — and, worse, you will not notice when a real leak hides inside that shape.

Gotcha· `performance.memory` is coarse without a flag

Chrome quantises performance.memory.usedJSHeapSize into ~5 MB buckets and updates it lazily, so a Playwright loop without --enable-precise-memory-info reports suspiciously round numbers and misses small regressions. It is also Chromium-only and absent in Firefox and WebKit, so gate the assertion on the browser name rather than letting the test throw on undefined.

Exercise

Exercise
  • Add the health route above to a scratch app, build it, and record rss/heapUsed/external before and after npx autocannon -c 20 -d 30.
  • Introduce the module-scope array leak, then run the full DevTools workflow: --inspect, warm up, snapshot A, load, collect garbage, snapshot B, Comparison sorted by size delta. Find your array in Retainers.
  • Add a setInterval in a universal plugin and watch Timeout entries multiply in process.getActiveResourcesInfo().
  • Run the same server under node --heapsnapshot-signal=SIGUSR2 and capture a snapshot with kill -USR2 instead of the inspector.
  • Write the memory watermark test, get it green, then re-introduce a leak and confirm it goes red.

Be able to say

Be able to say· A consumer reports that their pods restart with OOM a few hours after deploying your layer. How do you find the leak?

"First I separate growth from leakage: heaps grow at boot as JIT and caches fill, so the signal I want is a heap that does not come back down after a forced collection. I get a baseline from process.memoryUsage() on an internal route — heapUsed for JS objects, external and arrayBuffers if the growth is buffers or sockets rather than objects — and check process.getActiveResourcesInfo() for timers and handles piling up. Then I reproduce locally: run the built server with --inspect, warm it up, take snapshot A, load it with autocannon for thirty seconds, click collect garbage, take snapshot B, and read the comparison view sorted by size delta. I look at Array, Map, (closure), ReactiveEffect and my own component names, then open Retainers, because the answer is always who is holding the objects, not what they are. In production I use --heapsnapshot-signal=SIGUSR2 with kill -USR2 so I don't need an inspector port. Once I've found it I write the memory watermark test — boot the fixture, autocannon for thirty seconds, force GC behind a probe route with --expose-gc, assert heap growth under a threshold — so the regression can't come back silently."