金数据技术博客 · №21
The Cost of Cross-Site JavaScript: A Chrome 139 Default That Cost Us Twice
TL;DR
- Our in-app pages showed a 2–3 second white screen on first load, with LCP over 4 seconds
- Root cause:
LowPriorityAsyncScriptExecution, enabled by default since Chrome 139 — it delays the execution of cross-site async scripts, by up to 1 second - Our site runs on jinshuju.net, but our JS chunks are served from a CDN on jsjform.com — a direct hit
- The delayed chunks caused React hydration's scheduler rounds to explode from dozens to over a hundred thousand, saturating the main thread with 2+ seconds of scripting
- The fix: mark every Next.js JS chunk with
fetchpriority="high", which opts out of the delay
Before/after (home page):
| Scripting | LCP | |
|---|---|---|
| Before the fix | > 2000ms | ~4s |
| Chrome launched with the feature disabled | ~700ms | ~1.6–2s |
| After the fix (same account, same network) | ~700ms | ~2s |
Background
Jinshuju (金数据) is an online form builder SaaS. We deliberately keep the in-app product (form building, data management — on jinshuju.net) and published forms (public pages visited by respondents) on separate domains, while all JS chunks of the Next.js app are served from a CDN on jsjform.com.
This "site domain ≠ asset domain" combination made us pay tuition for the same lesson twice.
1. A Trap of Our Own Making
We had actually hit this trap before.
Published forms used to show a ~1 second white screen on cold load. Back then we narrowed it down to a performance problem with cross-site JS execution, so we moved our JS chunks to jsjform.com — the same domain as published forms. Published forms became same-site, and the problem went away.
We also documented the side effect at the time: the in-app product's domain no longer matched the chunk domain (jinshuju.net vs jsjform.com). But the call we made was: in-app users only cold-start once, everything afterwards is a warm cache, so the impact is negligible.
That call later turned out to be wrong — warm cache is affected just as much.
2. The In-App Product Got Too Slow
As features grew, so did the in-app code size. Opening the home page — especially on cold start — showed a 2–3 second white screen. Terrible experience.
Even after a round of chunk dieting, Chrome profiling still showed scripting taking over 2 seconds.
That's when we remembered the trap.
3. Agent-Assisted Diagnosis
The first time around we never found the true root cause. This time we re-investigated with Claude's help.
The agent ran controlled experiments over CDP against our production pages and CDN — same chunks, only the hosting page's domain varied:
| Page domain | Relation to chunks | ScriptDuration cold | ScriptDuration warm | Scheduler rounds cold | Scheduler rounds 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 (nonexistent domain) | 2,858 ms | 2,802 ms | 180,964 | 98,373 |
Cold or warm, cross-site is 12–55× slower than same-site.
Note: plain JS scripts don't suffer from this. It only gets amplified under React's cooperative scheduling — delayed chunks make hydration/Suspense retry over and over, and the exploding scheduler rounds are what saturate the main thread.
4. A Different Result Than Last Time
This problem is not limited to cold starts: with a warm cache, the gap is even wider.
For in-app users, this made it a must-fix.
5. Root Cause
Web searches turned up no related issues or articles at all.
Claude first ruled out 8 directions: process isolation, V8 code cache, timer precision and performance.now() overhead, MessageChannel RTT, raw CPU throughput, H2 connection reuse, byte size and compression, and our asset-domain allowlist logic. All clean.
Then, by bisecting Chrome versions, it pinned the regression to Chrome 139. Source analysis revealed the root cause:
Since Chrome 139, the Blink feature kLowPriorityAsyncScriptExecution (third_party/blink/common/features.cc) is enabled by default. It delays async script execution by up to 1 second.
Its key parameters:
| Parameter | Default | Meaning |
|---|---|---|
cross_site_only |
true |
Only applies to cross-site scripts |
main_frame_only |
true |
Only applies to the main frame (not iframes) |
exclude_non_parser_inserted |
false |
Dynamically inserted scripts are delayed too |
opt_out_high_fetch_priority_hint |
true |
fetchpriority=high opts out (no delay) |
6. Option 1: Move Everything to One Domain?
The first candidate fix: serve the site and its JS chunks from the same domain.
That's hard for us. The in-app product and published forms are deliberately isolated on different domains, and a single Next.js codebase only supports one assetPrefix. Going down this road would mean splitting in-app and published into separate deployments, or rewriting HTML responses through a proxy.
Too expensive. Abandoned.
7. Option 2: Add fetchpriority=high to Chunks
Chrome's source shows that fetchpriority=high lets a script bypass the delay mechanism entirely.
But Next.js generates chunk scripts internally and exposes no hook for this. So this option requires patching Next.js.
That's what we ended up doing.
8. The Patch
It takes one patch plus one piece of runtime code.
First, patch the Next.js runtime: when pushScriptImpl writes a script to the page, detect chunk scripts and add fetchPriority automatically:
t.src && t.src.includes("/_next/static/chunks/") && null == t.fetchPriority
&& (t = {...t, fetchPriority: "high"}),Second, on the client side, do the same when the Turbopack runtime appends scripts:
export function prioritizeNextChunkScript<T extends Node>(node: T): T {
if (node instanceof HTMLScriptElement && node.src) {
try {
if (new URL(node.src, location.href).pathname.includes(NEXT_CHUNK_PATH)) node.fetchPriority = 'high'
} catch {
// `src` reflects the raw attribute when it is not a parsable URL
}
}
return node
}After deploying, view-source confirms every chunk script carries fetchPriority="high":
The result (same account, same network): LCP dropped from 4+ seconds to ~2 seconds, scripting from 2+ seconds to ~500ms:
Known gap: React's bootstrap script (the one with id="_R_") cannot be marked with fetchPriority, but its impact on the result is minor.
9. What's Next
- The patch is a mitigation, not a cure. We're considering a PR to Next.js to make chunk script attributes configurable.
- Keep slimming down our chunks — less total script execution also means less sensitivity to delayed scheduling.
- Future Next.js upgrades need to keep the patch in sync.
Closing Thoughts
What two rounds of tuition taught us:
- "Site domain ≠ asset domain" is no longer just a DNS/caching-level decision — browsers now adjust script execution priority along site boundaries, so this combination directly affects runtime performance
- Chase performance problems all the way to the root cause. The first time, we stopped at the symptom level ("cross-site JS executes slowly") and picked a fix that happened to sidestep the problem — which is exactly how we walked into the same trap again on another domain pair


