Link to the code that reproduces this issue
https://github.com/flanker/nextjs-cross-site-chunk-delay
To Reproduce
The repro is deployed as two Vercel projects built from the same source with the same
ASSET_PREFIX, so they serve byte-identical HTML apart from the build ID. vercel.app is
on the Public Suffix List, so the two hosts are different sites, not merely different
origins. The only variable is the host in the address bar.
- git clone https://github.com/flanker/nextjs-cross-site-chunk-delay && cd $_
- Run the harness against both arms (Node >= 22, no npm dependencies):
node scripts/measure.mjs https://xsite-repro.vercel.app/ https://xsite-repro-doc.vercel.app/
- Run the control, which is what attributes the difference to Chrome rather than to
anything about the two hosts:
DISABLE_FEATURE=1 node scripts/measure.mjs https://xsite-repro.vercel.app/ https://xsite-repro-doc.vercel.app/
React's scheduler yields explode only when the chunks are cross-site and the Chrome
feature is on; disabling the feature collapses the difference (numbers under "Additional
context", including an honest note on what this repro does not show).
Current vs. Expected behavior
Current. Chrome 139 enabled the Blink feature kLowPriorityAsyncScriptExecution by
default. It delays execution of cross-site classic async scripts. App Router emits its
chunks as cross-site classic async scripts whenever assetPrefix points at a CDN on a
different registrable domain — the setup the assetPrefix docs describe. React hydration
then stalls waiting on chunks the browser has decided are not urgent. There is no warning
and no error; the only symptom is main-thread script time.
Defaults in third_party/blink/common/features.cc:
| parameter |
default |
effect |
| cross_site_only |
true |
only cross-site scripts are delayed |
| main_frame_only |
true |
only the main frame |
| exclude_non_parser_inserted |
false |
dynamically inserted scripts are delayed too |
| opt_out_high_fetch_priority_hint |
true |
fetchpriority="high" opts out |
Expected. Next marks its own chunk scripts fetchpriority="high" so they opt out of the
delay. That last parameter is the escape hatch Chrome provides for exactly this case, and
ReactDOM.preinit already accepts fetchPriority in PreinitOptions —
required-scripts.tsx simply does not pass it.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0
Available memory (MB): 36864
Available CPU cores: 12
Binaries:
Node: 22.16.0
npm: 10.9.2
pnpm: 10.33.4
Relevant Packages:
next: 16.2.6
react: 19.2.1
react-dom: 19.2.1
typescript: 5.9.3
Next.js Config:
output: standalone
Still present on canary. The measurements above are from 16.2.6, but the code path is
unchanged: packages/next/src/server/app-render/required-scripts.tsx is byte-identical
across v16.2.6, v16.3.1 and canary (md5 3d2d1d3e5be2f67b7060cd98ec4af7ce), and
fetchPriority does not appear in the file on any of them. Upgrading does not fix this.
Which area(s) are affected? (Select all that apply)
Performance, Runtime, Turbopack
Which stage(s) are affected? (Select all that apply)
next start (local), Vercel (Deployed), Other (Deployed)
Additional context
Where Next is exposed
App Router emits chunk scripts through three paths, and they do not all belong to Next:
| path |
emitted by |
can Next set fetchPriority today? |
| rootMainFiles[1..] |
ReactDOM.preinit in required-scripts.tsx |
yes |
| rootMainFiles[0] |
React bootstrapScripts (the id="_R_" script) |
no — BootstrapScriptDescriptor has no such field |
| client component chunks |
React Flight client-reference manifest → preinitScriptForSSR |
no |
Counted on one production App Router page with 78 chunk <script> tags in the SSR HTML:
10 from the first path, 1 from the second, 67 from the third. So a Next-only fix
is correct but partial; it is also the only part that can land without React API changes.
Controlled experiment on a production app
The same production HTML fulfilled under four document hosts via CDP Fetch.fulfillRequest,
every chunk served by the real CDN. Full request interception confirmed the arms were
byte-identical (55 CDN successes / 10 failures / 3,312,160 JS bytes, identical across arms)
and hydration succeeded in all four. Chrome 151.
| document host |
relation to chunks |
ScriptDuration cold |
warm |
scheduler yields cold / warm |
| im.jsjform.com |
same-site |
241 ms |
51 ms |
5,527 / 36 |
| zz.jsjform.com |
same-site |
260 ms |
48 ms |
6,710 / 23 |
| im.jinshuju.net |
cross-site |
2,997 ms |
2,807 ms |
109,876 / 98,487 |
| im.jsjform-x.net |
cross-site, domain does not resolve |
2,858 ms |
2,802 ms |
180,964 / 98,373 |
12–55×. The fourth arm uses a domain that does not exist, ruling out anything specific to
the third arm's host.
A warm cache does not help — it widens the gap (12× → 55×): same-site warm drops to
51 ms / 36 yields while cross-site barely moves.
Before / after in production
Marking the chunks fetchpriority="high" via a local patch (SSR path plus the Turbopack
client chunk loader), same account, machine and network:
|
LCP |
main-thread script |
| before |
3.5–4 s |
~2000 ms |
| Chrome with --disable-features=LowPriorityAsyncScriptExecution |
~1.6 s |
~700 ms |
| after the patch |
~2 s |
~700 ms |
The residual gap between rows 2 and 3 is the bootstrapScript, which the patch cannot
reach.
What the linked repro does and does not show
It demonstrates the mechanism, not the magnitude. 300 client components, 7 chunk scripts,
9 runs per cell, median [min–max]:
|
ScriptDuration |
scheduler yields |
| feature on, same-site |
21 ms [20–155] |
7 [7–18352] |
| feature on, cross-site |
23 ms [23–162] |
308 [8–19335] |
| feature off, same-site |
61 ms [60–75] |
15 [15–1076] |
| feature off, cross-site |
21 ms [20–78] |
9 [7–1573] |
The medians form the expected 2×2, but per-run ranges overlap heavily and ScriptDuration
shows no effect, so I would not lean on this alone — the production experiment above is the
controlled one. Two dead ends are recorded in the repro README: loopback hosts
(localhost vs 127.0.0.1) do not engage the feature at all, and raising the chunk count
via splitChunks.maxSize makes the effect disappear, because the pieces land in the entry
chunk group and are preinited before hydration starts. The trigger is chunks React must
wait on during hydration, not chunk count.
Fully closing this needs React
- fetchPriority accepted in BootstrapScriptDescriptor (preinit already accepts it)
- a way for a host framework to set fetchPriority on client-reference preinits
Happy to open a React issue for those, but wanted to check here first whether the Next team
would rather carry that conversation.
Link to the code that reproduces this issue
https://github.com/flanker/nextjs-cross-site-chunk-delay
To Reproduce
The repro is deployed as two Vercel projects built from the same source with the same
ASSET_PREFIX, so they serve byte-identical HTML apart from the build ID. vercel.app is
on the Public Suffix List, so the two hosts are different sites, not merely different
origins. The only variable is the host in the address bar.
anything about the two hosts:
React's scheduler yields explode only when the chunks are cross-site and the Chrome
feature is on; disabling the feature collapses the difference (numbers under "Additional
context", including an honest note on what this repro does not show).
Current vs. Expected behavior
Current. Chrome 139 enabled the Blink feature kLowPriorityAsyncScriptExecution by
default. It delays execution of cross-site classic async scripts. App Router emits its
chunks as cross-site classic async scripts whenever assetPrefix points at a CDN on a
different registrable domain — the setup the assetPrefix docs describe. React hydration
then stalls waiting on chunks the browser has decided are not urgent. There is no warning
and no error; the only symptom is main-thread script time.
Defaults in third_party/blink/common/features.cc:
Expected. Next marks its own chunk scripts fetchpriority="high" so they opt out of the
delay. That last parameter is the escape hatch Chrome provides for exactly this case, and
ReactDOM.preinit already accepts fetchPriority in PreinitOptions —
required-scripts.tsx simply does not pass it.
Provide environment information
Still present on canary. The measurements above are from 16.2.6, but the code path is
unchanged: packages/next/src/server/app-render/required-scripts.tsx is byte-identical
across v16.2.6, v16.3.1 and canary (md5 3d2d1d3e5be2f67b7060cd98ec4af7ce), and
fetchPriority does not appear in the file on any of them. Upgrading does not fix this.
Which area(s) are affected? (Select all that apply)
Performance, Runtime, Turbopack
Which stage(s) are affected? (Select all that apply)
next start (local), Vercel (Deployed), Other (Deployed)
Additional context
Where Next is exposed
App Router emits chunk scripts through three paths, and they do not all belong to Next:
Counted on one production App Router page with 78 chunk <script> tags in the SSR HTML:
10 from the first path, 1 from the second, 67 from the third. So a Next-only fix
is correct but partial; it is also the only part that can land without React API changes.
Controlled experiment on a production app
The same production HTML fulfilled under four document hosts via CDP Fetch.fulfillRequest,
every chunk served by the real CDN. Full request interception confirmed the arms were
byte-identical (55 CDN successes / 10 failures / 3,312,160 JS bytes, identical across arms)
and hydration succeeded in all four. Chrome 151.
12–55×. The fourth arm uses a domain that does not exist, ruling out anything specific to
the third arm's host.
A warm cache does not help — it widens the gap (12× → 55×): same-site warm drops to
51 ms / 36 yields while cross-site barely moves.
Before / after in production
Marking the chunks fetchpriority="high" via a local patch (SSR path plus the Turbopack
client chunk loader), same account, machine and network:
The residual gap between rows 2 and 3 is the bootstrapScript, which the patch cannot
reach.
What the linked repro does and does not show
It demonstrates the mechanism, not the magnitude. 300 client components, 7 chunk scripts,
9 runs per cell, median [min–max]:
The medians form the expected 2×2, but per-run ranges overlap heavily and ScriptDuration
shows no effect, so I would not lean on this alone — the production experiment above is the
controlled one. Two dead ends are recorded in the repro README: loopback hosts
(localhost vs 127.0.0.1) do not engage the feature at all, and raising the chunk count
via splitChunks.maxSize makes the effect disappear, because the pieces land in the entry
chunk group and are preinited before hydration starts. The trigger is chunks React must
wait on during hydration, not chunk count.
Fully closing this needs React
Happy to open a React issue for those, but wanted to check here first whether the Next team
would rather carry that conversation.