Heap snapshot diffs: how to prove a Next.js memory leak
Quick answer
Most memory leak reports die in the same place. Someone posts a graph that climbs, a maintainer asks for heap snapshots taken after a forced GC, and nobody comes back. I don't think that's laziness — taking those snapshots correctly is harder than the thread makes it sound, and getting it wrong gives you numbers that look convincing and mean nothing.
So I took one open issue and did it properly: vercel/next.js#95094, "Memory leak from retained sandbox timeouts causes stepwise heap growth". The claim is that setTimeout inside middleware leaks, because the sandbox keeps every timeout id and never lets go. (Both that issue and #94890 below are now fixed, and shipped in Next.js 16.3.0 — days after this went out. The method is the point here, not the ticket status.)
That claim is easy to believe and easy to get wrong. Stepwise growth is also what JIT warm-up and lazy caches look like. Telling those two apart is the whole job.
This post is about the method — how to measure, and how to prove the diagnosis. If what you actually need is to work out which of the open leaks you're hitting in production, read how to find a Next.js memory leak in production first and come back here to confirm it.
The protocol matters more than the tool
Every step here exists to kill one specific way of fooling yourself:
- A fresh process per route. Nothing carries over from the last thing you ran.
- Warm-up before the baseline. The first requests trigger JIT and lazy caches. Snapshot cold and you bill all of that to the leak.
- Forced GC — three passes — before every sample. Heap the GC can reclaim isn't leaked. Sample without forcing it and you're measuring garbage. This needs the process started with
--expose-gcsoglobal.gc()is callable, and the snapshot written withv8.writeHeapSnapshot()right after. - Cycles, not a single reading. One number tells you nothing. A series has a shape, and the shape is what survives noise.
- An audit of the load itself. A run that quietly failed to send its traffic looks exactly like a healthy route.
That last one isn't theoretical. Twice, my own load generator sent almost nothing — once because the timeout was expressed in whole seconds, once because 86% of the requests never finished connecting. Both would have printed a confident stable. I only caught them because the run records what it actually did.
The numbers
Against the issue's own reproduction repo — next 16.3.0-canary.50, Node 24.15, darwin arm64, 8 cycles of 3000 requests, 20 connections:
heap after forced GC, per cycle (MB):28.7 → 40.2 → 59.0 → 75.8 → 75.8 → 101.0 → 101.1 → 138.9 → 138.9
That's the stepwise shape from the title, about 4.7 MB per 1000 requests, and it never comes back. Warm-up flattens after a cycle or two. This gives back nothing.
The snapshot diff names the mechanism
A climbing heap tells you that you have a problem. It doesn't tell you what's holding the memory. The diff between the baseline and the final snapshot does:
grown [object] Array 112.47 MB — TimeoutsManager#object[.resources] <- system / Context#object[.timeoutsManager] <- webSetTimeoutPolyfill#closure[.context]
Read it upwards: the sandbox's setTimeout polyfill holds a context, the context holds a TimeoutsManager, and its resources array has grown to 112 MB of timeout entries. That's exactly the mechanism the issue describes — and I didn't have to know where to look to find it.
This is the same thing you'd read by hand in Chrome DevTools: load both snapshots in the Memory tab, switch to the Comparison view, and open the retainers of whatever grew. The snapshots are kept precisely so you can go and check it yourself — a verdict you can't audit is just an opinion with numbers attached.
Removing the cause removes the effect
This is the step people skip, and it's the only one that turns a measurement into a confirmation.
The thread suggests a workaround: call clearTimeout(id) inside the callback so the sandbox releases the entry. If my diagnosis is right, that should erase the curve. Same app, same parameters:
27.8 → 25.2 → 25.3 → 25.4 → 25.4 → 25.5 → 25.5 → 25.5 → 25.6
Flat. +0.02 MB per 1000 requests. Nothing changed except the cause, and the effect went with it. That's the standard I'd want from anyone showing me a leak.
One thing I didn't expect: at 100 concurrent connections, the fixed version timed out on about 9% of requests. Tracking 500 timeouts per request costs CPU as well as memory, so on hot middleware this matters beyond the heap.
The full write-up with the reproduction parameters is on the issue itself.
The same method says "no" just as often
I ran this against six open issues. Two more reproduced:
- #94890 — the router LRU cache doesn't count its keys. Heap 26.7 → 71.9 MB, with the chain pointing into the route filesystem checker. The original report needed 1.2M requests and a custom script; this took 120000 and one command.
- #84884 — axios with
AbortSignalin middleware. Four nearly identical routes in the repro, and only/middleware/axiosleaks: 32.8 → 369.9 MB while the other three stay flat. That's isolation by comparison, not a correlation you have to trust.
Three didn't reproduce — and the reasons were in the threads all along. One was fixed upstream in 16.0.3. One is a Node bug the reporter confirmed is gone in Node 26. One is a dev-server problem, which this method can't see by design.
A negative result with evidence behind it is worth as much as a confirmation. It's the difference between "I couldn't reproduce it" and "here is what I ran, here is what I saw, here is what I ruled out."
Across ~25 healthy routes on real production apps — PPR, MDX, Auth.js, Sentry, i18n — it reported no false positives. That number matters more to me than the confirmations. A leak detector that cries wolf is worse than not having one.
Why not just use Chrome DevTools or memlab?
Because none of them run the experiment for you, and the experiment is the hard part:
| Tool | What it gives you | Where it stops |
|---|---|---|
| Chrome DevTools | The ground truth: two snapshots and a Comparison view | You reproduce the load, force the GC, pick the moments and read the retainers yourself. Doing that correctly is the whole difficulty |
| memlab | An excellent heap-analysis engine — I use it to parse the snapshots | It's built around browser scenarios you script. It doesn't drive HTTP load against your routes, and it knows nothing about Next.js route manifests or your build's source maps |
| clinic.js | Broad Node performance profiling | Its own README says it is no longer actively maintained |
--inspect + manual snapshots | Full control | Same as DevTools, plus keeping the process, the load and the snapshots in sync by hand |
What's missing from all of them isn't the analysis — it's the controlled experiment around it: a fresh process per route, warm-up before the baseline, forced GC and an adaptive idle before every sample, and an audit of whether the load you think you sent actually landed.
Measuring your own app
I automated the protocol above into a CLI, next-leak:
npx next-leak .
It needs App Router, output: "standalone" and Node ≥ 22. That's a narrow scope on purpose — it's why the false positive count is what it is. You get one of three answers, and all of them are useful: you don't have a leak (the common case, and the report proves it), the leak is yours or a dependency's (named down to the file when the sourcemaps allow it), or it's framework internals, with a draft issue and the snapshots to back it.
Without next-leak: the protocol in two files
That narrow scope leaves a lot of servers out. This blog runs on the Pages Router, so next-leak can't measure it either. The protocol doesn't need the tool, though, and the core of it fits in two files you can drop next to any Node server.
The first is a probe you preload into the server process. Every SIGUSR2 runs three forced GC passes, logs heapUsed, and writes a snapshot. The baseline snapshot is kept; every later one overwrites heap-latest, so you end with exactly the pair you need to compare.
// heap-probe.cjs// Load with: node --expose-gc --require ./heap-probe.cjs <your server entry>// Send SIGUSR2 to take one sample: three forced GC passes, then heapUsed.const path = require('node:path');const v8 = require('node:v8');if (typeof global.gc !== 'function') { throw new Error('heap-probe: start node with --expose-gc');}// Captured before the server starts: Next's standalone server.js calls process.chdir(__dirname).const outDir = process.cwd();// Cycle 1 still carries warm-up that the GC gives back a cycle later, so the baseline is cycle 2.const BASELINE_CYCLE = 2;let cycle = 0;process.on('SIGUSR2', () => { for (let pass = 0; pass < 3; pass++) global.gc(); const heapMb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); if (cycle >= BASELINE_CYCLE) { const name = cycle === BASELINE_CYCLE ? 'heap-baseline.heapsnapshot' : 'heap-latest.heapsnapshot'; v8.writeHeapSnapshot(path.join(outDir, name)); } console.error(`[heap-probe] cycle ${cycle}: ${heapMb} MB after GC`); cycle++;});
The second drives the cycles: one cold sample, then eight rounds of load followed by a sample.
#!/usr/bin/env bash# measure.sh — usage: ./measure.sh <server entry> <url>node --expose-gc --require ./heap-probe.cjs "$1" &PID=$!sleep 2for cycle in 0 1 2 3 4 5 6 7 8; do if [ "$cycle" -gt 0 ]; then npx autocannon -c 20 -a 3000 "$2" > /dev/null 2>&1 fi kill -USR2 "$PID" sleep 5 # raise it if your snapshots take longer to writedonekill "$PID"
For a Next.js standalone build, point it at the generated server and one route:
./measure.sh .next/standalone/server.js http://localhost:3000/api/your-route
I checked it against a standalone Next.js 15.5 build with two API routes doing the same work, except that one also pushes each request into a global array:
heap after forced GC, per cycle (MB):/api/leak 24.8 → 28.6 → 26.4 → 27.5 → 28.5 → 29.5 → 30.6 → 31.6 → 32.6/api/clean 24.8 → 27.6 → 24.4 → 24.4 → 24.5 → 24.5 → 24.5 → 24.5 → 24.5
Same curve shape as #95094, at a smaller scale: about 1 MB per 3000 requests that never comes back, next to a route that goes flat.
Two things I hit while testing changed the probe. Both routes are higher after the first cycle than after the second — 28.6 then 26.4, 27.6 then 24.4. That's warm-up the GC gives back one cycle late, so my first version, which took its baseline after cycle 1, was diffing against an inflated heap. The baseline is cycle 2 now. The other one was quieter: the first run finished with no snapshots where I expected them, because the standalone server.js calls process.chdir(__dirname) on startup and they'd been written inside .next/standalone. The probe now records the directory it was started from before the server gets the chance to move.
Then load heap-baseline.heapsnapshot and heap-latest.heapsnapshot into Chrome DevTools and read the retainers exactly as in the snapshot diff section above.
What this doesn't do is what next-leak adds on top: a fresh process per route, an adaptive idle before each sample, the audit of whether the load actually landed, and a verdict. The script throws autocannon's output away to keep the log readable. Drop the > /dev/null on your first run and check that every round really sent its 3000 requests before you trust a flat line.
If you're coming at this from the other end — a graph climbing in production and no idea why — start here instead: How to find a Next.js memory leak in production.
