"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.
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.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.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".--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.performance.memory.usedJSHeapSize, or memlab running a scenario, turns "I checked" into a test that fails in CI.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(),
}
})
# 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.
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:
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 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.
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() }
})
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)
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.
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.
rss/heapUsed/external before and after npx autocannon -c 20 -d 30.--inspect, warm up, snapshot A, load, collect garbage, snapshot B, Comparison sorted by size delta. Find your array in Retainers.setInterval in a universal plugin and watch Timeout entries multiply in process.getActiveResourcesInfo().node --heapsnapshot-signal=SIGUSR2 and capture a snapshot with kill -USR2 instead of the inspector."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."
Client-side leaks
The eleven ways a long-lived browser tab keeps hold of unmounted components — listeners, timers, observers, third-party instances, orphaned effects and growing global state — with the Nuxt and VueUse fixes.
Patterns to adopt
The rules a layer or module author follows so the leak catalogues become someone else's reading — lifetimes, scopes, bounded caches, cleanup contracts, and the review checklist every PR goes through.