Cookies
This website uses cookies to improve the user experience, more information on the legal information path.
Notes
0
years
0
mons
0
days
0
hour
0
mins
0
secs
00
This is the background image
7

How to measure a Next.js memory leak and prove the diagnosis

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.

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-gc so global.gc() is callable, and the snapshot written with v8.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 AbortSignal in middleware. Four nearly identical routes in the repro, and only /middleware/axios leaks: 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:

ToolWhat it gives youWhere it stops
Chrome DevToolsThe ground truth: two snapshots and a Comparison viewYou reproduce the load, force the GC, pick the moments and read the retainers yourself. Doing that correctly is the whole difficulty
memlabAn excellent heap-analysis engine — I use it to parse the snapshotsIt'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.jsBroad Node performance profilingIts own README says it is no longer actively maintained
--inspect + manual snapshotsFull controlSame 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.

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.