Follow-up to #1632, which collapsed 52 render-blocking stylesheets to 7 and took FCP from 2000 ms to 1200 ms on staging. A measurable stall remains, and this is the other half of it.
The remaining stall
<head> contains exactly one parser-blocking element: an inline script emitted by <Script src="/scripts/translate-dom-guard.js" strategy="beforeInteractive" /> (apps/web/src/app/layout.tsx:115), which renders as
<script>(self.__next_s=self.__next_s||[]).push(["/scripts/translate-dom-guard.js",{}])</script>
An inline classic script cannot execute while any stylesheet is still pending, so the HTML parser stops there until CSS arrives. The other inline script in <head> is application/ld+json, which never executes and does not block.
Measured on staging after #1638, gap between the document finishing download and the first ParseHTML event with endLine > 0, 3 runs:
| run |
before #1638 |
after #1638 |
| 1 |
693 ms |
756 ms (cold CSS cache) |
| 2 |
485 ms |
145 ms |
| 3 |
487 ms |
144 ms |
Median 487 ms -> 145 ms. #1638 shortened the wait by removing 46 of the sheets it waits on; it cannot reach zero while the script is parser-blocking, because the remaining global sheet still has to arrive.
This is why acceptance criterion 2 on #1632 ("parser resumes within 50 ms of document load_end") was left unmet.
The premise for beforeInteractive does not hold
The comment on that line says beforeInteractive "installs the Node.prototype patch before React hydrates, so it's in place for the first commit". The guarantee is real, but it does not come from the script's position in <head>.
Verified in the pinned next@15.5.21: dist/client/app-bootstrap.js:52-53 runs appBootstrap(hydrate) -> loadScriptsInSequence(self.__next_s, ...) and only then hydrates. The inline tag in <head> just pushes an entry onto an array; the actual <script> element is created by the client bootstrap at hydration time, after the app JS has loaded. So the ordering guarantee is enforced by the bootstrap, not by the document position, and the parser-blocking inline tag buys nothing for correctness.
Proposed change
Replace it with a plain async tag:
<script async src="/scripts/translate-dom-guard.js" />
An async script is never parser-blocking, so the parser continues through the document while CSS is in flight. The file is 2,771 bytes and same-origin, so it should land well before the ~1 MB of app JS that hydration waits on, which would make the guard available earlier than it is today, not later.
Keep import Script from "next/script" — it is still used by the Plausible tag further down the same file.
What to verify before trusting the win
I would not merge this on the reasoning above alone. Two things need measuring, because Chrome's behaviour here is subtler than "async is not blocking":
- Does the parser gap actually close? Chrome delays execution of classic scripts, async included, until stylesheets that block scripts have loaded. Async removes the parsing block for certain; whether it also moves the guard's execution earlier is the open question. Measure the same gap metric (document
load_end -> first ParseHTML with endLine > 0) over 3 runs on staging.
- Does the guard still beat React's first commit? This is the risk that matters. It patches
Node.prototype.insertBefore/removeChild to survive in-page translators rewriting the DOM; if it lands after the first commit, the crash it prevents (NotFoundError, escapes error boundaries, full-page 500) comes back.
Rollback signal
Its own commit, reverted on its own. Trigger: any new NotFoundError: Failed to execute 'removeChild'/'insertBefore' on 'Node' in Sentry on a release carrying this change. That signature is unambiguous.
Baseline for comparison, captured on the staging box: WPT 260822_8Z_2 (3 runs, Cable, desktop Chrome, direct to the app container), FCP 1200 ms / LCP 1667 ms / parser gap median 145 ms.
Follow-up to #1632, which collapsed 52 render-blocking stylesheets to 7 and took FCP from 2000 ms to 1200 ms on staging. A measurable stall remains, and this is the other half of it.
The remaining stall
<head>contains exactly one parser-blocking element: an inline script emitted by<Script src="/scripts/translate-dom-guard.js" strategy="beforeInteractive" />(apps/web/src/app/layout.tsx:115), which renders asAn inline classic script cannot execute while any stylesheet is still pending, so the HTML parser stops there until CSS arrives. The other inline script in
<head>isapplication/ld+json, which never executes and does not block.Measured on staging after #1638, gap between the document finishing download and the first
ParseHTMLevent withendLine > 0, 3 runs:Median 487 ms -> 145 ms. #1638 shortened the wait by removing 46 of the sheets it waits on; it cannot reach zero while the script is parser-blocking, because the remaining global sheet still has to arrive.
This is why acceptance criterion 2 on #1632 ("parser resumes within 50 ms of document
load_end") was left unmet.The premise for
beforeInteractivedoes not holdThe comment on that line says
beforeInteractive"installs the Node.prototype patch before React hydrates, so it's in place for the first commit". The guarantee is real, but it does not come from the script's position in<head>.Verified in the pinned
next@15.5.21:dist/client/app-bootstrap.js:52-53runsappBootstrap(hydrate)->loadScriptsInSequence(self.__next_s, ...)and only then hydrates. The inline tag in<head>just pushes an entry onto an array; the actual<script>element is created by the client bootstrap at hydration time, after the app JS has loaded. So the ordering guarantee is enforced by the bootstrap, not by the document position, and the parser-blocking inline tag buys nothing for correctness.Proposed change
Replace it with a plain async tag:
An async script is never parser-blocking, so the parser continues through the document while CSS is in flight. The file is 2,771 bytes and same-origin, so it should land well before the ~1 MB of app JS that hydration waits on, which would make the guard available earlier than it is today, not later.
Keep
import Script from "next/script"— it is still used by the Plausible tag further down the same file.What to verify before trusting the win
I would not merge this on the reasoning above alone. Two things need measuring, because Chrome's behaviour here is subtler than "async is not blocking":
load_end-> firstParseHTMLwithendLine > 0) over 3 runs on staging.Node.prototype.insertBefore/removeChildto survive in-page translators rewriting the DOM; if it lands after the first commit, the crash it prevents (NotFoundError, escapes error boundaries, full-page 500) comes back.Rollback signal
Its own commit, reverted on its own. Trigger: any new
NotFoundError: Failed to execute 'removeChild'/'insertBefore' on 'Node'in Sentry on a release carrying this change. That signature is unambiguous.Baseline for comparison, captured on the staging box: WPT
260822_8Z_2(3 runs, Cable, desktop Chrome, direct to the app container), FCP 1200 ms / LCP 1667 ms / parser gap median 145 ms.