next-leak 0.9.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 +68 -13
- package/dist/{chunk-EJ26OBZI.js → chunk-5ZBIS5WM.js} +34 -2
- package/dist/{chunk-UVA77PMC.js → chunk-75W2KIXN.js} +1369 -1133
- package/dist/{chunk-WXAFXVWS.js → chunk-AUUMRTZZ.js} +5 -1
- package/dist/{chunk-T7LM4Z33.js → chunk-SV47LIER.js} +8 -3
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli.js +27 -4
- package/dist/heap-diff.d.ts +18 -1
- package/dist/{html-report-5XMR3EXE.js → html-report-YWIK5EDQ.js} +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -4
- package/dist/{issue-report-HKA26WNV.js → issue-report-FPVOZ6HU.js} +1 -1
- package/dist/ritual.d.ts +6 -0
- package/dist/runner.d.ts +36 -0
- package/dist/self-check.d.ts +50 -0
- package/dist/trend.d.ts +22 -1
- package/package.json +1 -1
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-
|
|
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 |
|
|
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** |
|
|
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
|
|
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
|
-
|
|
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. **
|
|
80
|
-
3. **
|
|
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,7 +139,15 @@ 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`
|
|
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
|
|
|
@@ -149,7 +161,8 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
149
161
|
|
|
150
162
|
`--write-config` generates that file for you. It takes the *shape* of each value
|
|
151
163
|
from paths your build already prerendered, and makes the value itself move:
|
|
152
|
-
`post-0` becomes `post-{n}
|
|
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
|
|
153
166
|
guaranteed not to measure anything — every request hits the same warm cache
|
|
154
167
|
entry, so the route reads as flat whatever it retains, and that false negative
|
|
155
168
|
lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
|
|
@@ -202,6 +215,7 @@ separates them, because each one has a different fix:
|
|
|
202
215
|
| One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
|
|
203
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 |
|
|
204
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 |
|
|
205
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 |
|
|
206
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 |
|
|
207
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 |
|
|
@@ -210,7 +224,12 @@ separates them, because each one has a different fix:
|
|
|
210
224
|
|
|
211
225
|
- **`stable`** — no growth this run could detect: across the cycles it ran, the
|
|
212
226
|
post-GC curve never cleared the growth gate printed at the foot of the
|
|
213
|
-
report.
|
|
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
|
|
214
233
|
is deliberately biased toward missing a leak rather than inventing one (a
|
|
215
234
|
single flat or falling cycle is enough to call a route stable), so a leak
|
|
216
235
|
that oscillates while it climbs can land here. To press harder, raise
|
|
@@ -220,9 +239,24 @@ separates them, because each one has a different fix:
|
|
|
220
239
|
flat but RSS keeps climbing, the report says so explicitly: that is an
|
|
221
240
|
allocator, external-buffer or fragmentation problem, not a JS-heap leak.
|
|
222
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.
|
|
223
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.
|
|
224
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.
|
|
225
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.
|
|
259
|
+
|
|
226
260
|
## Peak pressure: `stable` is not the same as safe
|
|
227
261
|
|
|
228
262
|
Every verdict above is about what a route **retains** after idle and a forced
|
|
@@ -336,6 +370,20 @@ than a false accusation, and the warnings are on the report either way.
|
|
|
336
370
|
└── <nn>-<route>/ # raw baseline/after .heapsnapshot per route
|
|
337
371
|
```
|
|
338
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
|
+
|
|
339
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.
|
|
340
388
|
|
|
341
389
|
## Why not just use…
|
|
@@ -356,6 +404,7 @@ through the build's source maps.
|
|
|
356
404
|
|
|
357
405
|
## Scope and limits (read before filing issues)
|
|
358
406
|
|
|
407
|
+
|
|
359
408
|
- **Supported (default command):** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
|
|
360
409
|
- **Sample values can vary per request.** `"slug": "post-{n}"` gives every
|
|
361
410
|
request its own URL — the shape of bot traffic and of a cache that never
|
|
@@ -381,10 +430,16 @@ through the build's source maps.
|
|
|
381
430
|
once for an hour and a half. Use it on a reproduction you are investigating,
|
|
382
431
|
not on a build you need to finish. It also covers only a low slice of the
|
|
383
432
|
curve, and says which: a snapshot weighs roughly 0.4x to 0.8x the worker's
|
|
384
|
-
resident size
|
|
385
|
-
|
|
386
|
-
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
|
|
387
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.
|
|
388
443
|
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
389
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.
|
|
390
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
|
|
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 = {
|
|
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
|
}
|