Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Avoid using unclosing prefetch streams in the browser by unstubbable · Pull Request #89610 · vercel/next.js · GitHub
Skip to content

Avoid using unclosing prefetch streams in the browser - #89610

Merged
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak
Feb 9, 2026
Merged

Avoid using unclosing prefetch streams in the browser#89610
unstubbable merged 1 commit into
canaryfrom
hl/unclosed-streams-leak

Conversation

@unstubbable

@unstubbableunstubbable commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Previously, createPrefetchResponseStream intentionally never called controller.close() on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the reader.read().then(progress) closure chain — from being garbage-collected.

Now that React Flight supports unstable_allowPartialStream as an option for createFromReadableStream (react/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary createUnclosingPrefetchStream wrapper from the legacy prefetch path in fetch-server-response.ts.

closes#89485

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

@nextjs-bot

nextjs-bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 1 improvement

MetricCanaryPRChangeTrend
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

MetricCanaryPRChangeTrend
Cold (Listen)456ms455ms▁▁▁▁▁
Cold (Ready in log)438ms437ms▁▂▁▂▂
Cold (First Request)1.165s1.175s▁▅▁▅▅
Warm (Listen)457ms457ms▁▁▁▁▁
Warm (Ready in log)442ms447ms▁▁▁▁▁
Warm (First Request)335ms340ms▁▁▁▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

MetricCanaryPRChangeTrend
Cold (Listen)455ms455ms▁▁▁▁▁
Cold (Ready in log)439ms439ms▆▅▃▆▂
Cold (First Request)1.818s1.785s▃▃▃▃▂
Warm (Listen)456ms456ms▁▁▁▁▁
Warm (Ready in log)438ms438ms▄▅▃▄▂
Warm (First Request)1.823s1.809s▃▃▃▃▂

⚡ Production Builds

MetricCanaryPRChangeTrend
Fresh Build3.819s3.755s▁▁▁▁▁
Cached Build3.826s3.820s▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

MetricCanaryPRChangeTrend
Fresh Build13.903s13.928s▁▁▃▁▁
Cached Build14.038s13.972s▁▁▃▁▂
node_modules Size467 MB467 MB🟢 58.5 kB (0%)▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -41 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
CanaryPRChange
middleware-b..fest.js gzip764 B759 B
Total764 B759 B✅ -5 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip450 B448 B
Total450 B448 B✅ -2 B

📦 Webpack

Client

Main Bundles
CanaryPRChange
5528-HASH.js gzip5.47 kBN/A-
6280-HASH.js gzip57 kBN/A-
6335.HASH.js gzip169 BN/A-
912-HASH.js gzip4.53 kBN/A-
e8aec2e4-HASH.js gzip62.5 kBN/A-
framework-HASH.js gzip59.7 kB59.7 kB
main-app-HASH.js gzip256 B254 B
main-HASH.js gzip39.1 kB39.1 kB
webpack-HASH.js gzip1.68 kB1.68 kB
262-HASH.js gzipN/A4.53 kB-
2889.HASH.js gzipN/A169 B-
5602-HASH.js gzipN/A5.49 kB-
6948ada0-HASH.js gzipN/A62.5 kB-
9544-HASH.js gzipN/A57.6 kB-
Total230 kB231 kB⚠️ +601 B
Polyfills
CanaryPRChange
polyfills-HASH.js gzip39.4 kB39.4 kB
Total39.4 kB39.4 kB
Pages
CanaryPRChange
_app-HASH.js gzip194 B194 B
_error-HASH.js gzip183 B180 B🟢 3 B (-2%)
css-HASH.js gzip331 B330 B
dynamic-HASH.js gzip1.81 kB1.81 kB
edge-ssr-HASH.js gzip256 B256 B
head-HASH.js gzip351 B352 B
hooks-HASH.js gzip384 B383 B
image-HASH.js gzip580 B581 B
index-HASH.js gzip260 B260 B
link-HASH.js gzip2.49 kB2.49 kB
routerDirect..HASH.js gzip320 B319 B
script-HASH.js gzip386 B386 B
withRouter-HASH.js gzip315 B315 B
1afbb74e6ecf..834.css gzip106 B106 B
Total7.97 kB7.97 kB✅ -1 B

Server

Edge SSR
CanaryPRChange
edge-ssr.js gzip126 kB126 kB
page.js gzip249 kB249 kB
Total375 kB376 kB⚠️ +409 B
Middleware
CanaryPRChange
middleware-b..fest.js gzip614 B613 B
middleware-r..fest.js gzip156 B155 B
middleware.js gzip33.1 kB33.3 kB
edge-runtime..pack.js gzip842 B842 B
Total34.7 kB35 kB⚠️ +261 B
Build Details
Build Manifests
CanaryPRChange
_buildManifest.js gzip733 B735 B
Total733 B735 B⚠️ +2 B
Build Cache
CanaryPRChange
0.pack gzip3.84 MB3.85 MB🔴 +7.72 kB (+0%)
index.pack gzip103 kB103 kB
index.pack.old gzip103 kB104 kB🔴 +1.61 kB (+2%)
Total4.05 MB4.06 MB⚠️ +9.15 kB

🔄 Shared (bundler-independent)

Runtimes
CanaryPRChange
app-page-exp...dev.js gzip315 kB315 kB
app-page-exp..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip315 kB315 kB
app-page-tur..prod.js gzip167 kB167 kB
app-page-tur...dev.js gzip312 kB312 kB
app-page-tur..prod.js gzip166 kB166 kB
app-page.run...dev.js gzip312 kB312 kB
app-page.run..prod.js gzip166 kB166 kB
app-route-ex...dev.js gzip70.5 kB70.5 kB
app-route-ex..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.5 kB70.5 kB
app-route-tu..prod.js gzip49 kB49 kB
app-route-tu...dev.js gzip70.1 kB70.1 kB
app-route-tu..prod.js gzip48.8 kB48.8 kB
app-route.ru...dev.js gzip70.1 kB70.1 kB
app-route.ru..prod.js gzip48.7 kB48.7 kB
dist_client_...dev.js gzip324 B324 B
dist_client_...dev.js gzip326 B326 B
dist_client_...dev.js gzip318 B318 B
dist_client_...dev.js gzip317 B317 B
pages-api-tu...dev.js gzip43.2 kB43.2 kB
pages-api-tu..prod.js gzip32.9 kB32.9 kB
pages-api.ru...dev.js gzip43.1 kB43.1 kB
pages-api.ru..prod.js gzip32.8 kB32.8 kB
pages-turbo....dev.js gzip52.5 kB52.5 kB
pages-turbo...prod.js gzip39.4 kB39.4 kB
pages.runtim...dev.js gzip52.5 kB52.5 kB
pages.runtim..prod.js gzip39.3 kB39.3 kB
server.runti..prod.js gzip62.7 kB62.7 kB
Total2.8 MB2.8 MB✅ -138 B
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch 2 times, most recently from 6777d99 to bd2abb5CompareFebruary 9, 2026 11:33
Previously, `createPrefetchResponseStream` intentionally never called
`controller.close()` on the wrapper stream, to prevent React Flight from
erroring on unresolved references (PPR dynamic holes). However, Chrome
and Firefox keep unclosed ReadableStreams with pending reads as native GC
roots, preventing the stream — and the entire FlightResponse captured in
the `reader.read().then(progress)` closure chain — from being collected.
Now that React Flight supports `unstable_allowPartialStream`, we can
close the stream normally. Flight will mark unresolved chunks as
"halted" instead of erroring, which is the correct behavior for prefetch
responses.
Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper
from the legacy prefetch path in `fetch-server-response.ts`.
@unstubbable
unstubbableforce-pushed the hl/unclosed-streams-leak branch from bd2abb5 to 168b8ffCompareFebruary 9, 2026 19:37
@unstubbableunstubbable changed the title [wip] Avoid using unclosed streams in the browserAvoid using unclosing prefetch streams in the browserFeb 9, 2026
@unstubbable
unstubbable marked this pull request as ready for review February 9, 2026 21:57
@unstubbable
unstubbable merged commit 839fe54 into canaryFeb 9, 2026
269 of 281 checks passed
@unstubbable
unstubbable deleted the hl/unclosed-streams-leak branch February 9, 2026 22:30
unstubbable added a commit that referenced this pull request Feb 10, 2026
Follow-up to #89610. Adds an e2e test that detects linear heap growth
caused by unclosed prefetch response streams. The test cycles through
mounting/unmounting links to trigger repeated prefetches and LRU
evictions, then uses CDP heap profiling with linear regression to verify
the heap plateaus rather than growing steadily.
unstubbable added a commit that referenced this pull request Feb 10, 2026
…9756)
Follow-up to #89610.
Adds an e2e test that detects linear heap growth caused by unclosed
prefetch response streams. The test cycles through mounting/unmounting
links to trigger repeated prefetches and LRU evictions, then uses CDP
heap profiling with linear regression to verify the heap plateaus rather
than growing steadily.
![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)
@github-actionsgithub-actionsBot locked as resolved and limited conversation to collaborators Feb 24, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@unstubbable@nextjs-bot@acdlite