next-leak 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,22 +29,43 @@ next-leak found the growth, the retaining object and the chain that holds it —
29
29
  without being told what to look for. Next.js 16.3.0 has since fixed it.
30
30
 
31
31
  **Verified against real Next.js issues**, not synthetic fixtures. Issue states
32
- checked 2026-09-05:
32
+ checked 2026-09-19:
33
33
 
34
34
  | Issue | What it is | Measured | State today |
35
35
  |---|---|---|---|
36
- | [#97776](https://github.com/vercel/next.js/issues/97776) | `use cache`: `AbortSignal.any` composites never released (canonical issue; #97938 and #97464 are duplicates) | +705 KB per request on 16.3.3, flat on 16.2.6 (the #97938 run) | **open** — fix [#97476](https://github.com/vercel/next.js/pull/97476) merged Aug 19, in no stable release as of 16.3.4 |
37
- | [#97938](https://github.com/vercel/next.js/issues/97938) | `cacheComponents`: composite abort signal never released | +705 KB per request on 16.3.3, flat on 16.2.6 | closed Aug 31 as a duplicate of #97776; fix in no stable release |
36
+ | [#97938](https://github.com/vercel/next.js/issues/97938) | `use cache` / `cacheComponents`: `AbortSignal.any` composites never released (canonical issue since Sep 9; #97776 and #97464 are duplicates) | +705 KB per request on 16.3.3, flat on 16.2.6 | fixed in **16.3.5** (Sep 11) by [#98448](https://github.com/vercel/next.js/pull/98448), the backport of [#97476](https://github.com/vercel/next.js/pull/97476); re-measured Sep 14: +709 KB per request on 16.3.4, flat on 16.3.5 |
37
+ | [#97776](https://github.com/vercel/next.js/issues/97776) | `use cache`: the same composites, reported from production | the #97938 run above | closed Sep 9 as a duplicate of #97938; fixed in 16.3.5 |
38
38
  | [#96533](https://github.com/vercel/next.js/issues/96533) | ISR revalidation holds RSC buffers between collections | 4–5 MB of `arrayBuffers` held vs 0.32 MB retained | **open** |
39
- | [#97464](https://github.com/vercel/next.js/issues/97464) | Static-gen worker retains per prerendered page | OOM after 1617 and 1525 of 2504 pages on 16.3.3; 16.2.12 finishes at 0.05 MB/page | closed Aug 31 as a duplicate of #97776; fix in no stable release |
40
- | [#97802](https://github.com/vercel/next.js/issues/97802) | Turbopack compilation saturates the container before rendering anything | Reporter's reproduction in a 2 CPU / 4 GB container: 16.2.12 peaks ~2 GB and finishes; 16.3.0 pins 4 GB and stalls | **open** — bisected to 16.3.0; no fix known |
39
+ | [#97464](https://github.com/vercel/next.js/issues/97464) | Static-gen worker retains per prerendered page | OOM after 1617 and 1525 of 2504 pages on 16.3.3; 16.2.12 finishes at 0.05 MB/page | closed Aug 31 as a duplicate; same fix, in 16.3.5 — not re-measured here yet |
40
+ | [#97802](https://github.com/vercel/next.js/issues/97802) | Turbopack compilation saturates the container before rendering anything | not attributable to a version: see below. [#98581](https://github.com/vercel/next.js/pull/98581) (merged Sep 16, in `16.4.0-canary.34`) doesn't move it: cold build peak 5,161 MB on canary.33 and 5,279 MB on canary.35 (`next-leak build`); under 4 GB neither finishes compiling in 600 s, under 5 GB both compile in about a minute | **open** — the other fix from Vercel, [#98611](https://github.com/vercel/next.js/pull/98611), is still a draft |
41
+ | [#98707](https://github.com/vercel/next.js/issues/98707) | `next dev`: a route handler compiled after N pages costs ~16 MB × N | reproduced independently (10 pages, 34 handlers, Node 24.18): 851 MB on `16.3.0-canary.100`, **2,354 MB on canary.101**, 1,704 MB on 16.3.5; 1,461 MB requesting the handlers first | **open** — the reporter's bisect to `16.3.0-canary.101` holds |
41
42
  | [#92287](https://github.com/vercel/next.js/issues/92287) | Cache Components: unbounded `arrayBuffers` under load | 37.5 MB of arrayBuffers held between collections, 37x what it retains (16.3.1) | **open** |
42
- | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | 32.8 → 369.9 MB | **open** |
43
+ | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware: a reference cycle through undici's `Request` finalizer, closed when Turbopack's scope hoisting inlines axios's `composeSignals` | +17.02 MB/1000 req on 16.3.5 (Node 24.18), +4.62 (Node 24.21); flat with scope hoisting off | **open** — still leaks on 16.3.5 and 16.4.0-canary.29; fix proposed in undici ([nodejs/undici#5822](https://github.com/nodejs/undici/pull/5822)); workaround: `experimental: { turbopackScopeHoisting: false }` |
43
44
  | [#89091](https://github.com/vercel/next.js/issues/89091) | zlib retention on mid-stream aborts | +42.5 MB/1000 aborted req on 16.1.5; **+0.03 on 16.3.1** | closed |
44
45
  | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | 112 MB retained; flat after the fix | fixed in 16.3.0 |
45
46
  | [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | 26.7 → 71.9 MB | fixed in 16.3.0 |
46
47
  | [#94919](https://github.com/vercel/next.js/issues/94919) | Retention on client aborts | 39 → 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) | fixed in 16.3.0 |
47
48
 
49
+ **#97802, and a measurement of ours that did not hold up.** Earlier versions of this
50
+ table said the regression was bisected to 16.3.0, on the strength of a 16.2.12 arm
51
+ that peaked around 2 GB while 16.3.x pinned the 4 GB ceiling. Re-measured on
52
+ 2026-09-16 in a real cgroup, that comparison falls apart:
53
+ `turbopackRustReactCompiler` does not exist before 16.3, so 16.2.12 rejects the key
54
+ and silently compiles through the Babel React Compiler while every 16.3 arm uses the
55
+ Rust one. Hold the version fixed and change only the compiler and
56
+ `16.3.0-canary.100` goes from 4,096 MiB with 12.3M at-limit events to 2,250 MiB with
57
+ zero. Give the Babel arms 2,400 s and neither 16.2.12 (2,180 MiB) nor 16.3.5
58
+ (1,692 MiB) ever reaches `Compiled successfully` — the low peak was unfinished work,
59
+ not headroom. With the compiler equalised and 6 GB of room, 16.3.5 is the only arm
60
+ that finishes the build at all, and what kills 16.2.12 and `canary.0` happens after
61
+ compilation, in prerendering. So this row no longer claims a version: the fixture
62
+ does not separate 16.2 from 16.3.
63
+
64
+ Two things worth taking from this if you measure builds yourself: when every arm
65
+ pins at exactly the cgroup ceiling, the ceiling is the measurement and not the
66
+ versions, so the peak cannot be the verdict; and an arm that never reaches the end
67
+ of compilation is not an arm that fits, however low its peak looks.
68
+
48
69
  The closed ones are kept deliberately: a tool that only lists open bugs
49
70
  looks impressive until the bugs close, and what those rows show is that the
50
71
  measurements matched what the fixes turned out to be. #94919 is the sharpest —
@@ -220,6 +241,7 @@ separates them, because each one has a different fix:
220
241
  | A route that is expensive, not leaky | `failed` under load it cannot sustain, flat once concurrency fits | Real leaks survive forced GC at any concurrency; saturation disappears when load drops |
221
242
  | Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
222
243
  | A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate |
244
+ | Memory a forced GC reclaims that production never reclaims in time | `pressure` | Every verdict sample is post-GC; the peaks are sampled under load, and a ceiling every settled cycle comes back to is a regime, not an episode |
223
245
  | Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
224
246
  | A leak in your code vs a dependency vs Next itself | `culprit: src/app/x/page.tsx (your code)` — or the package, or framework internals | Retainer chains mapped through the build's source maps |
225
247
  | A run whose own evidence is weak | `low confidence` warnings, or the verdict is withdrawn | Every run audits itself: did the load land, did the heap settle, does one cycle carry the average, did the heap run into its own ceiling |
@@ -256,6 +278,27 @@ separates them, because each one has a different fix:
256
278
  draft is generated. When the load was driving a cache with keys it had never
257
279
  served, the report says so on any growing route and points at `{n%N}` to
258
280
  bound the key set — measure again that way before believing the number.
281
+ - **`pressure`** — nothing is retained, and the process is still heading for a
282
+ ceiling. Every sample a verdict is computed from is taken after a forced GC,
283
+ and production runs none of those, so the retention verdicts above are
284
+ structurally blind to memory a full collection *does* reclaim but that the
285
+ runtime does not reclaim fast enough on its own. This is that case: the
286
+ post-GC curve is flat or falling, the peak sampled under load is far above
287
+ what the route retains, **and** every settled cycle reaches that height. On
288
+ the reproduction for [#92287](https://github.com/vercel/next.js/issues/92287)
289
+ the app allocated about 1 MB of `arrayBuffers` per request, passed 3 GB and
290
+ was OOM-killed — and the old verdict was `stable`, which is precisely the trap
291
+ this tool exists to warn other people about. A single high peak is *not* this:
292
+ one cycle that reached a ceiling for a reason that did not repeat stays
293
+ `stable` with a peak note, because an episode is not a regime. What separates
294
+ this from an app entitled to a large working set is not the shape of the
295
+ curve, it is that a working set survives the forced collection and lands in
296
+ what the route retains, which acquits it on the ratio alone. These
297
+ routes are not measured again (the run already saw the ceiling it is
298
+ reporting) and get no issue draft: the finding is real, but it is not
299
+ retention, so there is nothing for a snapshot diff to name. The fix is
300
+ usually allocation rate, response size or concurrency, not a missing
301
+ `delete`.
259
302
  - **`inconclusive`** — the evidence does not decide. The run does not stop there: any inconclusive route is **measured again automatically**, with twice the cycles, and the second pass is what you see (`resolved at 8 cycles` next to the verdict). On the reproduction for [#95094](https://github.com/vercel/next.js/issues/95094), `--quick` alone reports `inconclusive` on three deltas and then comes back with the leak. `--no-resolve` turns the second pass off; when even that is undecided, the re-run command is still printed.
260
303
  - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design. A process that died of **heap exhaustion** is not one of these: it reports `leak`, because a route that could not survive its own load did not fail to be measured — it was measured right up to the point where it stopped fitting. The verdict comes from that outcome, not from the shape of the truncated curve, which is the same rule `next-leak build` applies to a static-generation worker that dies. The run prints the cycles it survived and the growth up to the death, and exits 0 with a finding rather than 1 with an error.
261
304
 
@@ -282,9 +325,26 @@ That is a real measurement of the reproduction in
282
325
  app on 16.3.1 under a shorter profile still reaches 544 MB against 33.8 MB
283
326
  retained, so the shape has not gone anywhere. The note fires when the peak heap comes within
284
327
  75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
285
- and above 512 MB. It never changes the verdict — retention and peak are
286
- different questions, and only one of them is a leak. A peak is the highest
287
- value *sampled* (every 250 ms), so it is a lower bound.
328
+ and above 512 MB. A peak is the highest value *sampled* (every 250 ms), so it
329
+ is a lower bound.
330
+
331
+ A single high peak stays a note and leaves the verdict alone: one cycle that
332
+ reached a ceiling may have reached it for a reason that will not happen again.
333
+ When **every settled cycle** comes back to that height the verdict becomes
334
+ [`pressure`](#reading-the-verdicts) instead, because at that point the run is no
335
+ longer describing an episode, it is describing what the route does under load —
336
+ which is the number a container is sized against. The peak is not required to
337
+ climb, and asking it to would make the verdict unreachable: each cycle is
338
+ preceded by a forced collection and runs the same traffic, so the peak converges
339
+ on traffic × cost-per-request rather than ramping. Measured on the #92287
340
+ reproduction on 2026-09-24, the rss peaks across four cycles were 1229, 1344,
341
+ 1369 and 1379 MB — an asymptote.
342
+
343
+ What the route retains, for this ratio, is the **floor** of its post-GC cycle
344
+ samples rather than the last of them. Those samples follow a forced collection
345
+ but can still carry memory the collector had not reached yet, and on that same
346
+ reproduction they swung between 45 MB and 217 MB inside a single run, which by
347
+ itself decided whether the note appeared at all.
288
348
 
289
349
  If the measured process dies at the limit instead of merely approaching it,
290
350
  the route fails saying exactly that, with the limit in force and how to raise
@@ -56,7 +56,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
56
56
 
57
57
  ---
58
58
 
59
- ## @babel/generator@7.29.7
59
+ ## @babel/generator@7.29.8
60
60
 
61
61
  License: MIT
62
62
  https://babel.dev/docs/en/next/babel-generator
@@ -184,7 +184,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
184
184
 
185
185
  ---
186
186
 
187
- ## @babel/parser@7.29.7
187
+ ## @babel/parser@7.29.8
188
188
 
189
189
  License: MIT
190
190
  https://babel.dev/docs/en/next/babel-parser
@@ -245,7 +245,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
245
245
 
246
246
  ---
247
247
 
248
- ## @babel/traverse@7.29.7
248
+ ## @babel/traverse@7.29.8
249
249
 
250
250
  License: MIT
251
251
  https://babel.dev/docs/en/next/babel-traverse
@@ -460,7 +460,7 @@ SOFTWARE.
460
460
 
461
461
  ---
462
462
 
463
- ## @memlab/core@2.0.4
463
+ ## @memlab/core@2.0.5
464
464
 
465
465
  License: MIT
466
466
  https://github.com/facebook/memlab#readme
@@ -491,7 +491,7 @@ SOFTWARE.
491
491
 
492
492
  ---
493
493
 
494
- ## @memlab/e2e@2.0.4
494
+ ## @memlab/e2e@2.0.5
495
495
 
496
496
  License: MIT
497
497
  https://github.com/facebook/memlab#readme
@@ -522,7 +522,7 @@ SOFTWARE.
522
522
 
523
523
  ---
524
524
 
525
- ## @memlab/heap-analysis@2.0.4
525
+ ## @memlab/heap-analysis@2.0.5
526
526
 
527
527
  License: MIT
528
528
  https://github.com/facebook/memlab#readme
@@ -553,7 +553,7 @@ SOFTWARE.
553
553
 
554
554
  ---
555
555
 
556
- ## @memlab/lens@2.0.4
556
+ ## @memlab/lens@2.0.5
557
557
 
558
558
  License: MIT
559
559
  https://github.com/facebook/memlab#readme
package/dist/bootstrap.js CHANGED
@@ -3,6 +3,8 @@ import "./chunk-6XYFBOL2.js";
3
3
 
4
4
  // src/bootstrap.ts
5
5
  import { mkdir, writeFile } from "fs/promises";
6
+ import http2 from "http";
7
+ import https from "https";
6
8
  import path2 from "path";
7
9
 
8
10
  // src/control-server.ts
@@ -21,6 +23,7 @@ async function forceGc(passes = 3) {
21
23
  }
22
24
  return true;
23
25
  }
26
+ var requestProbe = globalThis;
24
27
  function sampleMemory(gcExposed) {
25
28
  const usage = process.memoryUsage();
26
29
  return {
@@ -28,7 +31,12 @@ function sampleMemory(gcExposed) {
28
31
  heapUsed: usage.heapUsed,
29
32
  rss: usage.rss,
30
33
  external: usage.external,
31
- arrayBuffers: usage.arrayBuffers
34
+ arrayBuffers: usage.arrayBuffers,
35
+ pid: process.pid,
36
+ ppid: process.ppid,
37
+ argv: process.argv,
38
+ cwd: process.cwd(),
39
+ ...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
32
40
  };
33
41
  }
34
42
  async function startControlServer(options) {
@@ -88,12 +96,30 @@ async function startControlServer(options) {
88
96
  };
89
97
  }
90
98
 
99
+ // src/request-probe.ts
100
+ function countServedRequests(module) {
101
+ const original = module.createServer;
102
+ module.createServer = function patched(...args) {
103
+ const server = original.apply(this, args);
104
+ server.on("request", () => {
105
+ requestProbe.__nextLeakServedRequests = (requestProbe.__nextLeakServedRequests ?? 0) + 1;
106
+ });
107
+ return server;
108
+ };
109
+ }
110
+
91
111
  // src/bootstrap.ts
92
112
  var workDir = process.env["NEXT_LEAK_DIR"];
113
+ function installRequestProbe() {
114
+ requestProbe.__nextLeakServedRequests = 0;
115
+ countServedRequests(http2);
116
+ countServedRequests(https);
117
+ }
93
118
  if (workDir !== void 0 && workDir !== "") {
94
119
  try {
95
120
  await mkdir(workDir, { recursive: true });
96
121
  const server = await startControlServer({ snapshotDir: workDir });
122
+ installRequestProbe();
97
123
  await writeFile(
98
124
  path2.join(workDir, `control-${process.pid}.json`),
99
125
  JSON.stringify({ port: server.port, pid: process.pid })
@@ -106,13 +106,17 @@ function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
106
106
  const external = classifyTrend(externalSamples, options);
107
107
  const severity = {
108
108
  leak: 0,
109
- inconclusive: 1,
110
- saturating: 2,
111
- stable: 3
109
+ pressure: 1,
110
+ inconclusive: 2,
111
+ saturating: 3,
112
+ stable: 4
112
113
  };
113
114
  if (severity[external.verdict] < severity[heap.verdict]) {
114
115
  return { ...external, source: "external" };
115
116
  }
117
+ if (severity[external.verdict] === severity[heap.verdict] && external.growthPerCycle > heap.growthPerCycle) {
118
+ return { ...external, source: "external" };
119
+ }
116
120
  return heap;
117
121
  }
118
122
 
@@ -125,7 +129,8 @@ var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
125
129
  "near-threshold",
126
130
  "spiky-growth",
127
131
  "thin-evidence",
128
- "repetitions-disagree"
132
+ "repetitions-disagree",
133
+ "cache-residency"
129
134
  ]);
130
135
  function warrantsIssueDraft(report) {
131
136
  return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
@@ -275,6 +280,15 @@ function thinEvidenceWarnings(trend, minGrowth) {
275
280
  detail: `judged on ${trend.deltas.length} cycles, and its weakest grew ${mb(smallest)} against a ${mb(minGrowth)} gate \u2014 on that few cycles ordinary oscillation reaches that, and a repeat run often disagrees; measure more cycles to resolve it`
276
281
  }];
277
282
  }
283
+ function cacheResidencyWarnings(trend, revalidatesFromCache) {
284
+ if (trend.cacheDriven !== true || trend.verdict !== "leak" || revalidatesFromCache !== true) {
285
+ return [];
286
+ }
287
+ return [{
288
+ code: "cache-residency",
289
+ detail: `every request asked this ISR route for a key it had never cached, so storing them is part of the ${mb(trend.growthPerCycle)}/cycle \u2014 and here the usual remedy does not apply: bounding the keys ({n%N}) hands the requests back to the ISR cache, so the run would drive revalidation and measure Next's other path rather than a cleaner version of this one. Run it both ways and compare before attributing this to anyone`
290
+ }];
291
+ }
278
292
  function isVerdictInvalid(input) {
279
293
  if (input.trend.verdict !== "leak") {
280
294
  return false;
@@ -295,6 +309,7 @@ function assessConfidence(input) {
295
309
  ...growthShapeWarnings(input.trend),
296
310
  ...noiseFloorWarnings(input.trend, minGrowth),
297
311
  ...thinEvidenceWarnings(input.trend, minGrowth),
312
+ ...cacheResidencyWarnings(input.trend, input.revalidatesFromCache),
298
313
  ...heapCeilingWarnings(input),
299
314
  ...warmUpBaselineWarnings(input)
300
315
  ];