金数据技术博客 · №21

The Cost of Cross-Site JavaScript: A Chrome 139 Default That Cost Us Twice

· flanker · frontend / React / nextjs · 中文版

中文版

TL;DR

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.

Before the fix: LCP 4.28s, scripting 2242ms

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":

view-source: chunks now carry fetchPriority=high

The result (same account, same network): LCP dropped from 4+ seconds to ~2 seconds, scripting from 2+ seconds to ~500ms:

After the fix: LCP 2.25s, scripting 509ms

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

  1. The patch is a mitigation, not a cure. We're considering a PR to Next.js to make chunk script attributes configurable.
  2. Keep slimming down our chunks — less total script execution also means less sensitivity to delayed scheduling.
  3. Future Next.js upgrades need to keep the patch in sync.

Closing Thoughts

What two rounds of tuition taught us: