Skip to content

Entry route: inline beforeInteractive script still blocks the HTML parser for ~145ms after the CSS fold #1641

Description

@feruzm

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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions