ParsingGENERALBROWSER-SPECIFICNETWORK-SPECIFIC

Why a Script Tag Stops the Parser

A classic <script> suspends tree construction because the script may write into the document at that exact point — and it also waits for pending stylesheets it may never touch.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What exactly happens when the parser reaches a <script> tag, and why does the page go quiet?

The user intent

Someone is waiting for content. Meanwhile the browser has reached a script tag and stopped building the document, for reasons entirely invisible from the outside.

The obvious build

Scripts are downloaded and run. Put them in the head so they are available early, and the browser handles the rest.

Why it breaks

A classic <script src> in the head suspends tree construction for the entire round trip: DNS if the origin is new, connection, TLS, request, response, then compile and execute. Nothing after that tag exists in the DOM during that time.

How it breaks in a real browser
  • A classic <script src> in the head suspends tree construction for the entire round trip: DNS if the origin is new, connection, TLS, request, response, then compile and execute. Nothing after that tag exists in the DOM during that time.
  • Nothing paints during that suspension either, because there is no more document to lay out. A single blocking script on a slow third-party origin can be the whole reason a page shows white.
  • The script sees only the DOM above itself. document.querySelector for anything further down the document returns null, which reads as an intermittent bug because it depends on where the tag ended up.
  • Scripts also wait for stylesheets. A pending stylesheet blocks script execution, so a two-line inline script can be delayed by a large CSS file it never reads (Render-Blocking Resources).
  • Inline scripts are not exempt. They have no network cost, but they still suspend parsing for their execution time and still wait on pending CSS.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • When tree construction reaches a <script> start tag with no async or defer, the element becomes "parser-inserted". The parser suspends and the script becomes pending.
  • The reason is document.write. A script may insert markup at exactly the parser's current insertion point, so the parser cannot legally continue past it without knowing the result (Tree Construction).
  • For an external script, the fetch happens now and parsing waits for it. For an inline script, there is nothing to fetch and execution begins as soon as it is allowed to.
  • It may not be allowed to immediately. If a stylesheet is still loading, the script is blocked until it finishes, because scripts can read computed style and the answer must be correct (The CSSOM).
  • Execution runs on the main thread, so it competes with everything else the thread owes the user: input, timers, rendering (What the Main Thread Owns).
  • When execution returns, parsing resumes from the insertion point — over whatever markup document.write inserted, if any.
  • The preload scanner keeps working throughout. It cannot build tree, but it can and does discover subresources further down the byte stream and start fetching them (The Preload Scanner).

What this makes the browser do

And which of it is avoidable.

  • A full network round trip during which tree construction, style, layout and paint all have nothing to do, for an external blocking script.
  • Compile and execute on the main thread. Modern engines parse and compile some script off-thread and cache compiled code across visits, but execution is always on the main thread (The Real Cost of JavaScript).
  • A style and layout pass after resumption, for the markup that had been waiting behind the script.
  • Speculative fetches from the preload scanner, which is exactly why a blocking script is not quite as catastrophic as the sequential model suggests.
  • Avoidable: essentially all of it, for essentially all scripts. defer and type="module" remove the suspension without giving up ordering (`defer`, `async` and `type="module"`).

What happens at the script token

The sequence below is what tree construction does when it meets a classic <script> with no loading attribute. Each step is a place the page can wait, and the last two are the ones people forget exist.

The important framing is that none of this is a performance heuristic. It is a correctness requirement: the script is allowed to insert markup at the parser's current position, and it is allowed to ask for computed style. Both facts force the browser to wait, and both are why defer — which promises the script will not do the first — can safely skip the wait.

Classic `<script src>` with no attribute
  1. 1
    Suspend tree construction

    The parser stops at the insertion point. No further elements are created, so nothing further can be styled, laid out or painted.

    fails by A blank page below the script tag for the whole duration, with no indication to the user that anything is happening.

  2. 2
    Resolve and connect

    If the script is on an origin not yet connected to, the browser performs DNS, connection setup and a TLS handshake before the request goes out.

    fails by A third-party origin on the critical path — several round trips before a single byte of script exists (The Three-Way Handshake in Networking).

  3. 3
    Fetch

    Requests the script, subject to CORS, CSP and any integrity check.

    fails by A slow or unavailable origin holds the parser for as long as the browser is willing to wait.

  4. 4
    Wait for pending stylesheets

    If any stylesheet is still loading, execution waits, because the script may read computed style and the answer must be final.

    fails by The surprising one: a fast script delayed by a large or slow CSS file it never touches (The CSSOM).

  5. 5
    Compile and execute

    Runs on the main thread, against the partial DOM built so far, with document.write able to insert at the insertion point.

    fails by A long script produces a long task: no input handling, no frames, no announcements (Long Tasks).

  6. 6
    Resume parsing

    Tree construction continues from the insertion point, over any markup the script wrote.

    fails by Content written by the script is parsed here, so a document.write of another script tag starts the whole cycle again.

Steps two and three disappear for an inline script. Steps one, four and five do not.

The dependency nobody expects: scripts wait on CSS

GENERALThe rule that a pending stylesheet blocks script execution is in the HTML Standard and applies in every mainstream engine. The precise set of stylesheets counted as "blocking scripts" — particularly for sheets added dynamically after parsing — has narrower rules that differ in edge cases between Blink and Gecko, so verify rather than reason about dynamically-injected CSS.

The blocking relationship between scripts and stylesheets is the least intuitive rule in the loading path, and it produces the most confusing waterfalls. A script — inline or external, in the head or the body — cannot execute while a stylesheet is still loading, because the script might call getComputedStyle and the browser is required to give it the right answer.

The consequence is a transitive block: CSS blocks the script, the script blocks the parser, and the parser blocks everything. A four-line inline snippet setting a global can end up gating the entire document behind a font stylesheet on a third-party origin. The fix is ordering, not size — put the inline script above the stylesheet link if it does not read style.

The same inline script, two positions in the headrelative units — a schematic shape, not a measurement
A — HTML head arrives
A — <link rel=stylesheet> discovered, fetch starts
A — inline script AFTER the link: blocked
A — inline script executes
A — parser resumes, body parsed
A — first paint
B — HTML head arrives
B — inline script BEFORE the link: executes
B — <link rel=stylesheet> discovered, fetch starts
B — parser continues, body parsed
B — first paint (waits for CSSOM)
  • A — <link rel=stylesheet> discovered, fetch startsThird-party origin: DNS, connect and TLS before any CSS bytes.
  • A — inline script AFTER the link: blockedIt reads no style at all. It waits anyway, because the stylesheet is pending.
  • B — inline script BEFORE the link: executesNo stylesheet is pending yet, so nothing blocks it.
  • B — <link rel=stylesheet> discovered, fetch startsOne unit later than A — a real cost, and much smaller than A's block.
  • B — parser continues, body parsedTree construction proceeds while CSS is in flight.
  • B — first paint (waits for CSSOM)Paint still waits for the render-blocking stylesheet. Everything else did not have to.

Both versions paint after the same stylesheet. B gets its tree built during the wait instead of after it, so the resumption work is already done — and the inline script's side effects happened at unit 2 rather than unit 11.

The one script that should still block

The rule is "default to non-blocking", not "never block". There is a small, real category of script that must run before the first paint: code that decides what the first paint should look like. A theme read from storage is the canonical example — deferring it means the user sees a light page flash to dark, which is worse than a marginally later paint and is a genuine accessibility problem for people sensitive to brightness changes.

What makes the exception safe is its shape: inline, tiny, no network, no dependencies, placed before the stylesheet so pending CSS cannot delay it. That is a very different object from a third-party tag in the head, even though both are technically "a blocking script".

Two blocking scripts in the head
A dependency you do not control
<head>
  <meta charset="utf-8">
  <script src="https://tags.example.com/loader.js"></script>
  <link rel="stylesheet" href="/app.css">
</head>

<!-- Parser suspended for: DNS + connect + TLS + request +
     response + compile + execute, on an origin you do not
     operate and cannot make faster. Nothing paints. -->
A decision the first paint needs
<head>
  <meta charset="utf-8">
  <script>
    // No network. No dependencies. Above the stylesheet,
    // so a pending sheet cannot delay it.
    try {
      const t = localStorage.getItem('theme')
      if (t) document.documentElement.dataset.theme = t
    } catch {}
  </script>
  <link rel="stylesheet" href="/app.css">
  <script defer src="https://tags.example.com/loader.js"></script>
</head>

The second script blocks for the duration of a synchronous storage read and nothing else, and it earns that by preventing a visible flash of the wrong theme — which is a contrast issue, not a cosmetic one (Contrast, Colour and Motion). The third-party loader has no such claim: nothing about the first paint depends on it, so defer costs it nothing and returns the entire round trip to the parser. The try/catch is not decoration — localStorage throws outright in some privacy configurations, and an exception here would take the theme with it (localStorage and sessionStorage).

How to build it

Most important first.

  • Default to defer or type="module" for anything that manipulates the page. Both keep document order and both run before DOMContentLoaded, and neither stops the parser.
  • Reserve a blocking script for the rare case that genuinely must run before the browser paints — a theme class that would otherwise flash, a feature-detection branch that decides which stylesheet to load. Keep it inline and tiny.
  • Keep such inline scripts *above* the stylesheet link if they do not read computed style, so that pending CSS does not delay them. Order in the head is a scheduling decision (The Head: Metadata That Changes Rendering).
  • Never load a third-party script synchronously. You are giving an origin you do not control the ability to stop your page (Third-Party Scripts and the Supply Chain).
  • If a script must run early and must not block, preload it and execute it with defer. The fetch starts immediately; the execution stays out of the parser's way (Resource Hints).
  • Delete document.write. Nothing needs it, and it is the sole reason for the blocking semantics that everything else in this lesson is working around.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A blocked parser means no content, and no content means nothing for a screen reader to read. The failure is total rather than degraded, and there is no visual cue in the assistive-technology experience that the page is merely busy.
  • Script execution on the main thread delays focus handling and announcements exactly as it delays paint. A long blocking script makes the first Tab feel broken (Long Tasks).
  • A script that runs before the content it enhances exists will fail to attach behaviour to it. Progressive enhancement depends on the script running after the markup, which is precisely what defer guarantees (Keyboard Operability).
  • Anti-flash scripts — the classic legitimate use of a blocking inline script — also cover a real accessibility need, since a flash of the wrong theme is a contrast and photosensitivity issue, not only a cosmetic one (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • A tag manager or analytics snippet installed as a synchronous script in the head, adding a full round trip to a third-party origin before any content can appear.
  • A small inline script blocked behind a large stylesheet, which presents as "our inline script is slow" and profiles as a wait rather than as work.
  • A script whose DOM query returns null because the element is below it, "fixed" with a setTimeout that works locally and fails on a slow connection.
  • document.write from a script that ran after parsing completed, which wipes the document — the specified behaviour, and always a bug.
  • The mitigation failing: adding defer to a script that another synchronous inline script below it depends on. The inline one now runs first and the global it expected does not exist yet (`defer`, `async` and `type="module"`).
What can arrive out of order
  • A blocking script and the stylesheet above it race in a fixed way — CSS wins — but which stylesheet is still pending depends on network arrival, so the same page can block for very different durations on identical hardware.
  • The preload scanner may have already requested a resource that a blocking script then removes or rewrites, so a fetch happens for something never used.
Security
  • A parser-inserted script runs with the full authority of the page: same origin, same cookies, same DOM. There is no reduced-privilege mode for a <script> tag (The Browser Security Model).
  • A synchronous third-party script is a synchronous dependency on someone else's availability *and* their integrity. Compromise of that origin is compromise of your page (Third-Party Scripts and the Supply Chain).
  • integrity (subresource integrity) makes the browser refuse a script whose hash does not match. It protects against a modified file, not against a maliciously-updated one you pinned the new hash for (Dependency Security).
  • CSP constrains which scripts may execute at all, including inline ones, which is why the anti-flash inline script needs a nonce or a hash in a strict policy (Content Security Policy).
  • The browser will not tell you that a blocking script is a risk. Nothing in this area is enforced; it is all your ordering decision.
Misreads
  • "Scripts at the end of the body do not block." They block the parser exactly the same way; there is simply less document left to block. Content above them still cannot be interactive until they run.
  • "Inline scripts are free because there is no network." They suspend parsing for their execution time and still wait on pending stylesheets.
  • "The preload scanner means blocking scripts do not matter." It recovers the *fetching*, not the tree construction, the style, the layout or the paint (The Preload Scanner).
  • "Scripts wait on CSS because of some rendering rule." They wait because a script may call getComputedStyle, and the browser cannot answer correctly with stylesheets outstanding. It is a correctness rule with a performance consequence.
  • "async fixes blocking." async removes the *parser* block but the script still executes on the main thread when it arrives, which can be mid-parse and can be worse (`defer`, `async` and `type="module"`).

Measuring it, and what changes in the field

How you would see this
  • The Performance panel shows the parser stopping: a gap in HTML parsing work with script evaluation inside it. The gap is the cost, and it is directly visible (A Mental Model of the Devtools).
  • The Network panel shows the request that the parser waited on, and its initiator. If the initiator is the parser rather than the preload scanner, it blocked (Reading a Network Waterfall).
  • Lighthouse and similar tools list render-blocking resources explicitly. Treat the list as a starting point and verify the ordering yourself.
  • Field data separates this from device speed: a blocking script hurts most on high-latency connections, which shows up as a spread in loading metrics rather than a shifted median (Vitals in the Field).
Slow device, slow network, large data, old tab
  • On a high-latency network the round trip dominates and a blocking script is the single most expensive structural mistake available. On a fast local connection it is nearly free, which is why it survives code review.
  • On a slow device compile and execute dominate instead, so a large blocking script hurts even when it is served from cache (The Real Cost of JavaScript).
  • On a repeat visit the script may come from the HTTP cache, removing the network cost but not the execution cost or the parser suspension (Browser HTTP Caching).
  • Behind an HTTP/2 or HTTP/3 connection that is already open the round trip is cheaper, because there is no connection setup. A new third-party origin still pays for DNS, connection and TLS (Keep-Alive and Connection Reuse in Networking).
What this costs
  • defer costs you the ability to run before content is parsed. For an anti-flash script, that is exactly the wrong trade — which is why a small blocking inline script remains correct for that one job.
  • Preloading a script raises its priority against everything else in flight. Doing it for several scripts spends the same budget in more places (Resource Hints).
  • Moving scripts out of the parser's path means more of them execute close together after parsing, which can produce one long task where there had been several short ones (Long Tasks).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALParser-blocking behaviour for classic scripts, and the rule that a script blocked on a pending stylesheet must wait, are both specified in the HTML Standard and hold across Blink, Gecko and WebKit. What differs is how each browser surfaces it: Chrome names render-blocking resources explicitly in Lighthouse, while Firefox and Safari require reading the waterfall yourself.
  • BROWSER-SPECIFICChrome ships an intervention that refuses to execute parser-blocking scripts inserted via document.write from a cross-origin, cacheable script on slow connections, logging a console warning instead. Firefox and Safari do not implement the same intervention, so a page relying on that pattern can behave differently between them.
  • NETWORK-SPECIFICThe cost of a blocking external script is dominated by round trips, so it scales with latency rather than bandwidth. On a fast, already-warm connection the same tag is close to free, which is exactly why the problem reaches production.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — what "compile and execute" actually costs: lazy parsing, bytecode generation, code caching across visits, and why a script served from cache is cheaper the second time even though the source is identical.
  • Software Design — document.write is the clearest example on the platform of one capability, kept for compatibility, forcing a pessimistic default on every user of the API forever.