vercel / vercel/next.js

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

Open
#96,202 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Performance Runtime
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
  1. npm install && npm run build && npm run start
  2. Launch Chrome (≥139) with the intervention forced onto same-origin scripts — this is exactly what cross-site assetPrefix CDN users get by default:
    chrome --user-data-dir=/tmp/chrome-repro --enable-features="LowPriorityAsyncScriptExecution:low_pri_async_exec_cross_site_only/false"
  3. DevTools → Performance → CPU: 6× slowdown (approximates production-sized bundle parse/hydration cost).
  4. Cold-navigate about:blankhttp://localhost:3000/ and measure performance.getEntriesByType('navigation')[0]loadEventStart - domContentLoadedEventEnd.
  5. 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:

  1. App Router emits client/page chunks into the SSR HTML as parser-inserted <script async> without fetchpriority, and there is no config to add it (only crossOrigin is plumbed through to these tags; the flight stream also emits no :H preload/preinit hints for client-reference JS chunks — serializeClientReference writes only :I rows).
  2. Chrome (M139+, default on desktop since Aug 2025 — ChromeOS later excluded, crbug.com/429069717; Android since Jan 2026) applies LowPriorityAsyncScriptExecution to cross-site async scripts, including these chunk tags: their evaluation task goes to a best-effort queue with a 1 s fallback (PostTaskWithLowPriorityUntilTimeout in third_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.
  3. During hydration, React (vendored 19.2.0-canary-0bdb9206-20250818) does not park quietly on the blocked flight chunk: handleThrow classifies every SuspenseException as SuspendedOnImmediate (the SuspendedOnData quiet-wait branch is unreachable — never assigned in this build), each cycle commits a ~1-fiber tree and scheduleRetryEffectclaimNextRetryLane spawns a fresh retry lane that markRootFinished fails to suspend, so ensureRootIsScheduled re-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_hint defaults to true), 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.