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.
What exactly happens when the parser reaches a <script> tag, and why does the page go quiet?
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.
Scripts are downloaded and run. Put them in the head so they are available early, and the browser handles the rest.
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.
- 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.querySelectorfor anything further down the document returnsnull, 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.
What is actually happening
In the browser, not in the framework.
- When tree construction reaches a
<script>start tag with noasyncordefer, 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.writeinserted, 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.
deferandtype="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.
- 1Suspend 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.
- 2Resolve 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).
- 3Fetch
Requests the script, subject to CORS, CSP and any
integritycheck.fails by A slow or unavailable origin holds the parser for as long as the browser is willing to wait.
- 4Wait 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).
- 5Compile and execute
Runs on the main thread, against the partial DOM built so far, with
document.writeable to insert at the insertion point.fails by A long script produces a long task: no input handling, no frames, no announcements (Long Tasks).
- 6Resume 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.writeof 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
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.
- A — <link rel=stylesheet> discovered, fetch starts — Third-party origin: DNS, connect and TLS before any CSS bytes.
- A — inline script AFTER the link: blocked — It reads no style at all. It waits anyway, because the stylesheet is pending.
- B — inline script BEFORE the link: executes — No stylesheet is pending yet, so nothing blocks it.
- B — <link rel=stylesheet> discovered, fetch starts — One unit later than A — a real cost, and much smaller than A's block.
- B — parser continues, body parsed — Tree 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".
<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. --><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
deferortype="module"for anything that manipulates the page. Both keep document order and both run beforeDOMContentLoaded, 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,
preloadit and execute it withdefer. 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
Tabfeel 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
deferguarantees (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
- 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
nullbecause the element is below it, "fixed" with asetTimeoutthat works locally and fails on a slow connection. document.writefrom a script that ran after parsing completed, which wipes the document — the specified behaviour, and always a bug.- The mitigation failing: adding
deferto 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"`).
- 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.
- 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.
- "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. - "
asyncfixes blocking."asyncremoves 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
- 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).
- 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).
defercosts 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.writefrom 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.
- — 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.writeis the clearest example on the platform of one capability, kept for compatibility, forcing a pessimistic default on every user of the API forever.