next-leak 0.8.0 → 0.10.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,20 +29,21 @@ 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-08-18:
32
+ checked 2026-08-27:
33
33
 
34
34
  | Issue | What it is | Measured | State today |
35
35
  |---|---|---|---|
36
+ | [#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 | **open** |
36
37
  | [#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** |
37
- | [#97464](https://github.com/vercel/next.js/issues/97464) | Static-gen worker retains per prerendered page | worker rss 1.07 2.96 GB, then OOM | **open** |
38
+ | [#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 | **open** |
38
39
  | [#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** |
39
40
  | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | 32.8 → 369.9 MB | **open** |
40
- | [#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** | open, no longer reproduces |
41
+ | [#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 |
41
42
  | [#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 |
42
43
  | [#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 |
43
44
  | [#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 |
44
45
 
45
- The three fixed ones are kept deliberately: a tool that only lists open bugs
46
+ The closed ones are kept deliberately: a tool that only lists open bugs
46
47
  looks impressive until the bugs close, and what those rows show is that the
47
48
  measurements matched what the fixes turned out to be. #94919 is the sharpest —
48
49
  the PR that closed it discarded the RSC-WeakMap hypothesis in the title and
@@ -73,11 +74,12 @@ provide heap snapshots taken after forced GC"* — which almost nobody produces
73
74
  correctly. `next-leak` runs that controlled measurement for you and answers with
74
75
  evidence a maintainer would accept.
75
76
 
76
- Three possible answers, all valuable:
77
+ The answers, all valuable:
77
78
 
78
79
  1. **You don't have a leak** — the spike is transient and drains during idle (the most common case).
79
- 2. **The leak is in your code (or a dependency)** named down to the source file when possible.
80
- 3. **It looks like framework internals** — with a ready-to-file issue draft.
80
+ 2. **Something is filling up, not leaking** a bounded cache on its way to its ceiling, which grows every cycle and still is not a leak.
81
+ 3. **The leak is in your code (or a dependency)** — named down to the source file when possible.
82
+ 4. **It looks like framework internals** — with a ready-to-file issue draft.
81
83
 
82
84
  ## Quickstart
83
85
 
@@ -120,7 +122,9 @@ Every report prints the gate it used.
120
122
  | `--max-old-space <mb>` | 512 | Heap cap of each measured process. Raise it for apps whose legitimate working set is larger, or they die under measurement |
121
123
  | `--quick` | off | Fast preset (2000 requests × 4 cycles, 8s idle) — the exact profile the real-app validation ran with. Same cycle count as the default; what it trades away is traffic per cycle, so it sits on the noise floor and is less sensitive to slow leaks. Explicit flags override it |
122
124
  | `--no-resolve` | off | Skip the second pass on inconclusive routes |
125
+ | `--self-check` | off | Measure a planted leak first to prove the harness works here. Costs one route's worth of time; a run that cannot detect 8 KB per request produces verdicts worth nothing |
123
126
  | `--diff-all` | off | Diff snapshots for stable routes too |
127
+ | `--attribute` | off | **`build` only.** Also name *what* the worker retains, not only that it retains |
124
128
  | `--output <dir>` | `<app>/.next-leak` | Where runs are written |
125
129
  | `--write-config` | off | Write `next-leak.config.json` for the routes that need sample params, then exit. Never overwrites an existing file |
126
130
 
@@ -135,21 +139,36 @@ A large site can run out of heap while prerendering, before any server exists to
135
139
  measure ([#97464](https://github.com/vercel/next.js/issues/97464)). That command
136
140
  runs your build unmodified and samples the resident memory of each
137
141
  static-generation worker. It needs neither a previous build nor standalone
138
- output, and takes `--output` only.
142
+ output, and takes `--output` and `--attribute`.
143
+
144
+ The report also prints what the build's **own process** reached, separately from
145
+ the worker's figure and labelled *reported, not judged*. The two are never added
146
+ together: the parent sheds memory while workers climb — 1.43 GB down to 0.10 GB
147
+ over the same window on the #97464 reproduction — so summing them cancels the
148
+ finding the worker verdict rests on. It is there because a build can fail
149
+ entirely in the parent, during compilation or file tracing, where no worker
150
+ exists yet.
139
151
 
140
152
  Dynamic routes need sample params in `next-leak.config.json` in your app dir:
141
153
 
142
154
  ```json
143
155
  {
144
156
  "params": { "lang": "en" },
145
- "routes": { "/products/[id]": { "id": "42" } },
157
+ "routes": { "/products/[id]": { "id": "42-{n}" } },
146
158
  "headers": { "accept-encoding": "gzip, br", "cookie": "session=..." }
147
159
  }
148
160
  ```
149
161
 
150
- `--write-config` generates that file for you, filling in values from paths your
151
- build already prerendered where it knows them, so it usually resolves on the
152
- first try. When a run skips a route it prints the same fragment.
162
+ `--write-config` generates that file for you. It takes the *shape* of each value
163
+ from paths your build already prerendered, and makes the value itself move:
164
+ `post-0` becomes `post-{n}`, and a value with no trailing number gets one
165
+ `seed` becomes `seed-{n}`. A value the build prerendered is the one value
166
+ guaranteed not to measure anything — every request hits the same warm cache
167
+ entry, so the route reads as flat whatever it retains, and that false negative
168
+ lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
169
+ and ISR all key on the params). If your app answers 404 for params it never
170
+ prerendered, the run says so through its non-2xx count; drop the marker then.
171
+ When a run skips a route it prints the same fragment.
153
172
 
154
173
  ```json
155
174
  ```
@@ -196,6 +215,7 @@ separates them, because each one has a different fix:
196
215
  | One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
197
216
  | 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 |
198
217
  | Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
218
+ | 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 |
199
219
  | Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
200
220
  | 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 |
201
221
  | 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 |
@@ -204,7 +224,12 @@ separates them, because each one has a different fix:
204
224
 
205
225
  - **`stable`** — no growth this run could detect: across the cycles it ran, the
206
226
  post-GC curve never cleared the growth gate printed at the foot of the
207
- report. That is not proof of absence, and the wording matters the verdict
227
+ report. Before trusting a page of these, prove the instrument works where you
228
+ are running it: `--self-check` plants a leak of 8 KB per request and measures
229
+ it, under your Node build, your heap cap, your concurrency and your container
230
+ limits. On this machine it comes back at 8.16 MB/1000 requests against a
231
+ theoretical 8.0 — a harness that cannot see that is one whose flat curves mean
232
+ nothing, and the run says so when nothing vouched for it. That is not proof of absence, and the wording matters — the verdict
208
233
  is deliberately biased toward missing a leak rather than inventing one (a
209
234
  single flat or falling cycle is enough to call a route stable), so a leak
210
235
  that oscillates while it climbs can land here. To press harder, raise
@@ -214,8 +239,23 @@ separates them, because each one has a different fix:
214
239
  flat but RSS keeps climbing, the report says so explicitly: that is an
215
240
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
216
241
  - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
242
+ - **`saturating`** — every cycle grew, but by less than the one before, and the
243
+ last by less than half the first. That is the shape of a bounded store
244
+ running out of new keys, not of memory going missing. It matters because the
245
+ alternative was calling it a leak: a `use cache` route measured on Next
246
+ 16.3.3 came out at +603 MB per 1000 requests that was entirely the cache
247
+ storing what it had been asked to store — the same route dropped to +88 MB
248
+ once the payload was removed. Because the shape requires every cycle to clear
249
+ the growth gate, a decelerating curve always ends the window still growing,
250
+ so where it settles is outside what was measured: these routes are
251
+ **measured again** with twice the cycles, like `inconclusive` ones. No issue
252
+ draft is generated. When the load was driving a cache with keys it had never
253
+ served, the report says so on any growing route and points at `{n%N}` to
254
+ bound the key set — measure again that way before believing the number.
217
255
  - **`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.
218
- - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
256
+ - **`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.
257
+
258
+ When a route is configured with a varying sample value (`{n}`) and *every* request comes back non-2xx, the run checks whether the value is the problem before blaming the route: it requests one value the marker would produce and one the build prerendered, and only when those disagree does it say so. An app with `generateStaticParams` and `dynamicParams = false` answers 404 for anything outside its param set, which is indistinguishable from a broken route in the counters alone. Bound the marker to the params that exist (`{n%N}`) or drop it.
219
259
 
220
260
  ## Peak pressure: `stable` is not the same as safe
221
261
 
@@ -330,6 +370,20 @@ than a false accusation, and the warnings are on the report either way.
330
370
  └── <nn>-<route>/ # raw baseline/after .heapsnapshot per route
331
371
  ```
332
372
 
373
+ `report.html` is the one you send to someone else. It is a real run below —
374
+ three routes measured with `--self-check`, on the reproduction this repo tests
375
+ against:
376
+
377
+ <img src="https://raw.githubusercontent.com/xabierlameiro/next-leak/main/docs/report-example.png" alt="next-leak HTML report: two stable routes and one leaking at +471 MB per 1000 requests, attributed to lib/sink.ts" width="720">
378
+
379
+ Per route it draws the post-GC curve, the peak reached *during* each cycle
380
+ across heap, external, arrayBuffers and RSS, and what grew between the two
381
+ snapshots — with its retained size and, when the source maps resolve it, the
382
+ file that owns it. No JavaScript, no external requests, no
383
+ fonts to fetch: it opens offline from a CI artifact. The file above is
384
+ [`docs/report-example.html`](./docs/report-example.html) if you want to poke at
385
+ the markup.
386
+
333
387
  Snapshots are the ground truth: load them in Chrome DevTools (Memory → Load → Comparison) and check every claim yourself. Runs accumulate — each keeps its snapshots (tens of MB per route); delete old timestamp folders when done.
334
388
 
335
389
  ## Why not just use…
@@ -350,6 +404,7 @@ through the build's source maps.
350
404
 
351
405
  ## Scope and limits (read before filing issues)
352
406
 
407
+
353
408
  - **Supported (default command):** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
354
409
  - **Sample values can vary per request.** `"slug": "post-{n}"` gives every
355
410
  request its own URL — the shape of bot traffic and of a cache that never
@@ -375,10 +430,16 @@ through the build's source maps.
375
430
  once for an hour and a half. Use it on a reproduction you are investigating,
376
431
  not on a build you need to finish. It also covers only a low slice of the
377
432
  curve, and says which: a snapshot weighs roughly 0.4x to 0.8x the worker's
378
- resident size, so past about a gigabyte it exceeds the 512 MB a V8 string can
379
- hold and cannot be read back both captures happen below that, and the
380
- report states what share of the observed growth they span (19% on that
433
+ resident size both captures happen well below the ceiling, and the report
434
+ states what share of the observed growth they span (19% on that
381
435
  reproduction, whose worker peaks near 4 GB).
436
+
437
+ That ceiling is not the file's size. memlab reads `nodes`, `edges` and
438
+ `locations` as typed arrays in chunks and only parses the rest, so what has
439
+ to fit in a 512 MB V8 string is everything else — and on a leaking snapshot
440
+ that is almost nothing. A 1342.9 MB capture of a leaking route parsed 2.4 MB
441
+ of JSON and diffs fine; a 2227.5 MB one whose `strings` section alone was
442
+ 868.9 MB is refused, and the message names 869 MB rather than 2227 MB.
382
443
  - **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
383
444
  - **Attribution** (naming the file) needs a Turbopack build with server sourcemaps — the Next 15+ default. On webpack builds the registry is empty by design and findings degrade to `unattributed` with raw retainer chains; measurement itself does not depend on it. Note that `output: "standalone"` + `--webpack` produced a bundle that could not start at all on `16.3.0-canary.90` (missing `@swc/helpers`), independently of this tool.
384
445
  - Empirically validated on Next **15.5.4, 16.0.x, 16.1.5, 16.2.x, 16.3.0/16.3.1 and 16.3-canary** (incl. Sentry, OpenTelemetry, PPR and i18n apps), against the public reproductions attached to real issues (open and since-fixed). Most recent measurements, 2026-08-17/18: the runtime path on 16.2.12 and 16.3.1-canary.18, the build path on 16.2.12 and 16.3.1. The contracts it relies on are stable since Next 13–14, but older versions are untested.
@@ -9,6 +9,8 @@ function minGrowthFor(requestsPerCycle) {
9
9
  var STEPWISE_MIN_GROWING_CYCLES = 2;
10
10
  var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
11
11
  var ACQUITTAL_MAX_GROWTH_MULTIPLE = 8;
12
+ var SATURATION_MIN_CYCLES = 3;
13
+ var SATURATION_MAX_FINAL_RATIO = 0.5;
12
14
  function maxDrawdown(samples) {
13
15
  let peak = samples[1] ?? 0;
14
16
  let worst = 0;
@@ -29,11 +31,29 @@ function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
29
31
  }
30
32
  return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
31
33
  }
34
+ function isSaturating(deltas) {
35
+ if (deltas.length < SATURATION_MIN_CYCLES) {
36
+ return false;
37
+ }
38
+ const first = deltas[0];
39
+ const last = deltas[deltas.length - 1];
40
+ if (first === void 0 || last === void 0 || first <= 0) {
41
+ return false;
42
+ }
43
+ for (let i = 1; i < deltas.length; i += 1) {
44
+ const current = deltas[i];
45
+ const previous = deltas[i - 1];
46
+ if (current === void 0 || previous === void 0 || current >= previous) {
47
+ return false;
48
+ }
49
+ }
50
+ return last <= first * SATURATION_MAX_FINAL_RATIO;
51
+ }
32
52
  function isTooLargeToAcquit(deltas, mean, minGrowth) {
33
53
  const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
34
54
  return mean >= minGrowth * ACQUITTAL_MAX_GROWTH_MULTIPLE && netGrowth > 0;
35
55
  }
36
- function classifyTrend(samples, options = {}) {
56
+ function classifyShape(samples, options) {
37
57
  const minGrowth = options.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
38
58
  if (samples.length < 4) {
39
59
  return { verdict: "inconclusive", growthPerCycle: 0, deltas: [] };
@@ -52,6 +72,9 @@ function classifyTrend(samples, options = {}) {
52
72
  const anyFlatOrDown = deltas.some((d) => d <= 0);
53
73
  const growingCycles = deltas.filter((d) => d >= minGrowth).length;
54
74
  if (allGrow) {
75
+ if (isSaturating(deltas)) {
76
+ return { verdict: "saturating", growthPerCycle: mean, deltas, source: "heap" };
77
+ }
55
78
  return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
56
79
  }
57
80
  if (isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth)) {
@@ -65,10 +88,19 @@ function classifyTrend(samples, options = {}) {
65
88
  }
66
89
  return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
67
90
  }
91
+ function classifyTrend(samples, options = {}) {
92
+ const result = classifyShape(samples, options);
93
+ return options.cacheDriven === true ? { ...result, cacheDriven: true } : result;
94
+ }
68
95
  function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
69
96
  const heap = classifyTrend(heapSamples, options);
70
97
  const external = classifyTrend(externalSamples, options);
71
- const severity = { leak: 0, inconclusive: 1, stable: 2 };
98
+ const severity = {
99
+ leak: 0,
100
+ inconclusive: 1,
101
+ saturating: 2,
102
+ stable: 3
103
+ };
72
104
  if (severity[external.verdict] < severity[heap.verdict]) {
73
105
  return { ...external, source: "external" };
74
106
  }