App Router: chunk <script> tags get no fetchpriority and Chrome's LowPriorityAsyncScriptExecution delays their evaluation — hydration render storm (~30k commits) on cross-site assetPrefix CDNs
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/minsgiman/nextjs-hydration-livelock-repro
To Reproduce
npm install && npm run build && npm run start- Launch Chrome (≥139) with the intervention forced onto same-origin scripts — this is exactly what cross-site
assetPrefixCDN users get by default:
chrome --user-data-dir=/tmp/chrome-repro --enable-features="LowPriorityAsyncScriptExecution:low_pri_async_exec_cross_site_only/false" - DevTools → Performance → CPU: 6× slowdown (approximates production-sized bundle parse/hydration cost).
- Cold-navigate
about:blank→http://localhost:3000/and measureperformance.getEntriesByType('navigation')[0]→loadEventStart - domContentLoadedEventEnd. - Control run: same steps with
--disable-features=LowPriorityAsyncScriptExecution.
Measured on the repro: DCL→load ~119 ms vs ~4 ms — the gap is purely chunk-evaluation delay (same-origin, downloads instant). On our production app (large bundle, ~29 chunk scripts) the delay reaches the intervention's full 1 s fallback timer, and React streaming hydration amplifies it into a render storm: ~30,000 one-fiber commits, ~53k RunMicrotasks, ~106k FunctionCalls in ~1 s on every cold navigation (Chrome trace: all chunks downloaded by ~110 ms; framework chunks evaluate ~150–175 ms; app chunks evaluate at ~1,148 ms — exactly when the commit storm ends). Verified causality in production both ways: --disable-features=LowPriorityAsyncScriptExecution eliminates the storm; forcing cross_site_only/false reproduces it on a same-site environment.
Current vs. Expected behavior
Current:
- App Router emits client/page chunks into the SSR HTML as parser-inserted
<script async>withoutfetchpriority, and there is no config to add it (onlycrossOriginis plumbed through to these tags; the flight stream also emits no:Hpreload/preinit hints for client-reference JS chunks —serializeClientReferencewrites only:Irows). - Chrome (M139+, default on desktop since Aug 2025 — ChromeOS later excluded, crbug.com/429069717; Android since Jan 2026) applies
LowPriorityAsyncScriptExecutionto cross-site async scripts, including these chunk tags: their evaluation task goes to a best-effort queue with a 1 s fallback (PostTaskWithLowPriorityUntilTimeoutinthird_party/blink/renderer/core/script/script_runner.cc). Chrome reads the priority from the ResourceRequest captured at fetch start (= HTML parse time), so nothing can be done from client-side JS after the fact. - During hydration, React (vendored
19.2.0-canary-0bdb9206-20250818) does not park quietly on the blocked flight chunk:handleThrowclassifies everySuspenseExceptionasSuspendedOnImmediate(theSuspendedOnDataquiet-wait branch is unreachable — never assigned in this build), each cycle commits a ~1-fiber tree andscheduleRetryEffect→claimNextRetryLanespawns a fresh retry lane thatmarkRootFinishedfails to suspend, soensureRootIsScheduledre-arms via microtask + MessageChannel continuation. Both runaway guards are bypassed on this path (the "Maximum update depth" counter only counts Sync/InputContinuous/Default lanes; the shell-suspend guard requires a null suspense handler). The resulting loop saturates the main thread and starves the very best-effort eval task it is waiting for — a livelock until Chrome's 1 s timer force-runs the chunk. Note the bootstrap script itself escapes the intervention (React emits a<link rel="preload" as="script" fetchpriority="low">for it, and link-preloaded resources are exempt); the storm is driven by the preinit-emitted app/client chunks.
Expected:
- A supported way to opt these scripts out. Chrome explicitly excludes
fetchpriority="high"scripts (opt_out_high_fetch_priority_hintdefaults totrue), so one attribute fully prevents the issue, e.g.:
// next.config.js
module.exports = {
assetPrefix: 'https://cdn.example.com', // different registrable domain
scriptFetchPriority: 'high', // applied to bootstrap/preinit/client chunk <script> tags
};
crossOrigin already demonstrates the plumbing for per-tag attributes, and React's preinit/bootstrapScripts APIs already accept fetchPriority — Next currently just doesn't pass one.
- Ideally, hydration should also degrade gracefully (quiet suspension instead of a retry storm) when a client chunk is downloaded but its evaluation is deferred by the browser.
Provide environment information
Operating System: macOS (Darwin 24.6.0) (also reproduced on production traffic, desktop Chrome)
Binaries: Node 22.12.0, npm 10.x
Relevant Packages: next 15.5.18, react 19.1.0, react-dom 19.1.0 (App Router uses the vendored react-dom 19.2.0-canary-0bdb9206-20250818)
Browser: Chrome ≥ 139 (intervention default-on: desktop since Aug 2025 (ChromeOS later excluded) — https://chromium-review.googlesource.com/c/chromium/src/+/6636695; Android since Jan 2026 — https://chromium-review.googlesource.com/c/chromium/src/+/7418954)
Which area(s) are affected? (Select all that apply)
Performance, Script (next/script), Runtime
Which stage(s) are affected? (Select all that apply)
next start (local), Other (Deployed)
Additional context
- Production evidence (controlled comparison, same code, only the CDN host's site relation differing): cross-site CDN → DCL→load ~1,000 ms / ~30,000 commits; same-site or same-origin → 1–30 ms / ~20–30 commits.
- Our current workaround is a custom-server streaming HTML transform that injects
fetchpriority="high"into every<script src=".../_next/static/chunks/...">tag (plus adocument.head.appendChildwrapper for late dynamically-inserted chunks). It fully resolves the issue but requires taking over compression and rewriting HTML — a heavy hammer for one attribute, and unavailable tonext startusers. - Related: #77239 (triaged) — delayed client-chunk execution breaking selective hydration (FOUC variant). This report is the same root phenomenon amplified into a render livelock by the Chromium intervention.
- Chromium rollout: enabled by default on desktop since M139 / Aug 2025 (ChromeOS later excluded — crbug.com/429069717) (https://chromium-review.googlesource.com/c/chromium/src/+/6636695), Android since Jan 2026 (https://chromium-review.googlesource.com/c/chromium/src/+/7418954) — which is why this regression appeared without any app deploy.
- Chromium references: feature defaults https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/common/features.cc (
low_pri_async_exec_*), eligibility https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script/classic_pending_script.cc
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked hydration-livelock reproduction, then trace App Router's parser-inserted and preinit-emitted chunk scripts, including the serializeClientReference path. Compare the fetchPriority plumbing in React's preinit/bootstrapScripts APIs with Next's script emission, and inspect the vendored React handleThrow retry path. Done means the supported opt-out works for CDN chunk tags and the reproduction no longer produces the delayed evaluation or retry storm.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nextjs, react
- Domain
- frontend, performance, web-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100