What blocks the parser and the first paint.
A browser that has your whole HTML file can still show a white screen. It is not slow at drawing; it is waiting, on purpose. It will not paint until it has the stylesheets it was told about, it stops reading the page whenever a plain script tag turns up, and it may draw your headline in invisible ink until a font arrives. Each of those waits protects something, and each has an attribute or a habit that turns it off when it is not needed. Knowing which is which is the difference between a page that appears at half a second and the same page appearing at well over one.
Builds on From bytes to pixels.
The idea.
The HTML arriving is not the page appearing. In between, the browser waits for some resources on purpose, and the order of a few tags in your head decides how long.
Copperpot, the kitchenware shop we follow through this card, has a product page for an enamel casserole. Open it on a mid-range phone over 4G and the server has sent every byte of HTML by 0.42 s. The screen stays white until 1.22 s, and even then the name, the price and the details are missing for another 0.3 s: the boxes are laid out, the bullets are there, the words are not. Nothing was slow to draw. The browser spent most of that second waiting, because the page told it to.
Each wait exists for a good reason. Painting before the stylesheet arrives would show raw, unstyled HTML and then jump, so the browser holds the first paint until the stylesheets it has seen in the head are ready: they are render-blocking. A plain script tag may call document.write or read a style and expect the page as it stands at that line, so the parser stops, fetches the script, waits for any pending stylesheet the script might read, runs it, and only then reads on: the script is parser-blocking. A heading set in a web font could flash from one typeface to another, so by default the browser draws that text invisibly for up to a few seconds while the font downloads.
Most pages do not need all of those protections for all of their resources. A print stylesheet cannot affect the screen. An analytics script does not care where the parser is. The app's own bundle only needs the finished DOM. Attributes such as media, async and defer tell the browser which waits it may skip, and a second, speculative parser starts downloads early even while the main one is stuck. This topic is about who waits for whom, and how to change it.
The same page at four moments
Every byte of HTML is here, yet nothing is drawn. The parser is stuck on the consent script in the head, which is still connecting to its own origin, and the document has no body yet, so it cannot render.
0.42 s, HTML complete. Document page: 0 blocks. Status: “Blank: waiting on the head” (busy).
Who waits for whom, by default
| In the HTML | Holds back the first paint | Stops the parser | Holds back scripts |
|---|---|---|---|
| <link rel=stylesheet> in head | Yes, until it loads | No | Yes: plain and deferred scripts wait for it |
| <link rel=stylesheet media=print> | No (media does not match) | No | No |
| <script src> (no attributes) | In the head, yes: no body exists while the parser is stopped | Yes, while it downloads and runs | - |
| <script> inline | In the head, yes | Yes, while it runs (and while it waits for pending CSS) | - |
| <script async src> | No | Only while it runs | - |
| <script defer src>, <script type=module> | No | No | - |
| <img src> | No | No | No |
| @font-face font | No, but text using it is drawn invisibly for a while | No | No |
How it works.
Six parts take turns on Copperpot's page: the parser, a second parser that reads ahead, the network, the style engine, the script engine and the renderer. Who is allowed to go next depends on a handful of rules.
Copperpot's head, as it was, from first byte to readable text
- Network service → HTML parser (reply): first HTML chunk (300 ms)
- HTML parser → Network service: GET main.css (matches screen: render-blocking)
- HTML parser → Network service: GET print.css (media=print: low priority, nobody waits)
- HTML parser → Network service: GET consent.js from a new origin
- Note over HTML parser: plain script: tokenizer stops here
- Preload scanner → Network service: read ahead: GET vendor.js, product.js, hero photo, reviews.js
- Network service → Style engine (reply): main.css (700 ms): CSSOM built
- Network service → HTML parser (reply): consent.js (760 ms)
- HTML parser → Script engine: run consent.js, then the inline snippet
- Network service → HTML parser (reply): vendor.js (900 ms)
- HTML parser → Script engine: run vendor.js, then product.js (to 1,110 ms)
- Note over HTML parser: resumes; body parsed by 1,160 ms
- HTML parser → Renderer: body exists and nothing is render-blocking
- Renderer → Network service: GET Fraunces (style finds text that uses it)
- Note over Renderer: first paint 1,220 ms, text invisible
- Network service → Renderer (reply): font (1,500 ms): text drawn
Stylesheets hold back the paint
The HTML Standard keeps a list on every document: the elements it is waiting for before it may render. A stylesheet link or a style element that the parser creates joins that list if its media attribute matches the current screen, and leaves it once the sheet and the resources it needs to be applied have loaded. While the list has anything in it, or while the document has no body element yet, the document is render-blocked: rendering opportunities come and go and the browser skips them. Browsers also keep a safety timeout of their own choosing, so a stylesheet that never finishes cannot hold the page back for ever.
Two details follow from that wording. First, elements can only join the list while there is no body yet, so it is the head's stylesheets that gate the first paint. Second, the media check is made against the environment right now: media=print, or a min-width the phone does not reach, means the sheet is still downloaded, at a low priority, but it blocks nothing.
The same matching, parser-created stylesheets also block scripts. Any plain or deferred script waits until no such sheet is still loading, because the script might read a computed style or a box size, and an answer based on half the CSS would be wrong. That is why a tiny inline script placed after a stylesheet link is not free: it cannot run, and the parser cannot move past it, until the stylesheet has arrived. Moving the inline snippet above the link, when it does not read styles, removes that wait.
What each link costs the first paint
<!-- waits: matches the screen, so it blocks rendering and later scripts -->
<link rel="stylesheet" href="/css/main.css">
<!-- fetched at low priority, blocks nothing on screen -->
<link rel="stylesheet" href="/css/print.css" media="print">
<!-- blocks only on wide screens; on Copperpot's 393 px phone it does not -->
<link rel="stylesheet" href="/css/wide.css" media="(min-width: 64em)">
<!-- this inline script cannot run until main.css has loaded -->
<script>window.cpLayer = [{ page: "product", sku: "CAS-24-TEAL" }];</script>
Plain scripts stop the parser
A script tag with a src and no async or defer is parser-blocking. When the parser reaches it, it stops building the DOM, waits for the file to download, waits for any stylesheet that blocks scripts, runs the script, and only then reads the next tag. An inline script skips the download but not the rest. In the head this also means no body exists yet, so nothing can paint while the parser is stopped there.
Two attributes change the deal for external scripts. async says: download alongside the parser and run the moment you arrive, whenever that is. The parser is only paused while the script actually runs, but the order between async scripts is whatever order the downloads finish in, and an async script does not wait for stylesheets. defer says: download alongside the parser, then run after parsing has finished, in the order the tags appear, before DOMContentLoaded. A deferred script does wait for script-blocking stylesheets, so it always sees the page's styles.
Module scripts (type=module) are deferred without being asked, and the defer attribute does nothing on them; add async to make a module run as soon as it and its imports are ready. Inline classic scripts ignore both attributes. Scripts that other scripts insert with createElement are async by default; setting script.async = false on them makes a group of injected scripts run in the order they were inserted, which is how loaders keep dependencies in line without blocking the parser.
Two scripts, three ways to load them
Scenario 1 of 3: As described.
Timeline as a list
Two scripts, three ways to load them: 3 lanes, from 0 ms to 600 ms.
- 0–50 ms · HTML parser · parse
- 50–400 ms · HTML parser · stopped at a.js
- 50–350 ms · a.js (300 ms download) · download
- 60–160 ms · b.js (100 ms download) · early fetch
- 350–400 ms · a.js (300 ms download) · run
- 400–420 ms · HTML parser ·
- 420–440 ms · HTML parser · b.js
- 420–440 ms · b.js (100 ms download) · run
- 440–570 ms · HTML parser · parse
- 570 ms · HTML parser · DOMContentLoaded (ok)
Script loading at a glance
| Tag | Download | Runs | Order kept | Waits for stylesheets | DOMContentLoaded waits |
|---|---|---|---|---|---|
| <script src> | Parser stops for it | At its place in the HTML | Yes | Yes | Yes |
| <script> (inline) | None | At its place in the HTML | Yes | Yes | Yes |
| <script async src> | In parallel | As soon as it arrives | No | No | No |
| <script defer src> | In parallel | After parsing ends | Yes, document order | Yes | Yes |
| <script type=module> | In parallel, with its imports | After parsing ends | Yes, document order | Yes | Yes |
| <script type=module async> | In parallel, with its imports | As soon as the graph is ready | No | No | No |
| injected with createElement | In parallel | As soon as it arrives (async by default) | Only with script.async = false | No | No |
A document's readiness, from first byte to load
States of1HTML parser
The states the parser moves the document through. readyState only has three values (loading, interactive, complete); the machine splits them where the rules for scripts change. DOMContentLoaded is the arrow out of the deferred scripts, not the end of parsing.
| From → To | Event | Guard | Action |
|---|---|---|---|
| Parsing (loading) → Stopped at a plain script | <script> without async/defer | ||
| Stopped at a plain script → Parsing (loading) | fetched and run | no CSS blocking scripts | |
| Parsing (loading) → Parsing (loading) | async script arrives | pause while it runs | |
| Parsing (loading) → Running deferred scripts (interactive) | last byte parsed | readyState = interactive | |
| Running deferred scripts (interactive) → DOMContentLoaded fired | deferred and module scripts ran | fire DOMContentLoaded | |
| DOMContentLoaded fired → Loaded (complete) | nothing delays the load event | fire load |
- Parsing (loading)start
- readyState is loading; plain scripts run in place
- Stopped at a plain script
- Waiting for its download and for script-blocking CSS
- Running deferred scripts (interactive)
- readyState is already interactive
- DOMContentLoaded fired
- Images, async scripts and frames may still be loading
- Loaded (complete)end
- readyState complete, then the load event
The second parser that reads ahead
If a stopped parser were the whole story, every file after a plain script would start downloading only once that script had run, and a head with three scripts would cost three round trips in a row. Browsers avoid that with a speculative parser, usually called the preload scanner. While the real parser is stuck, it reads on through the raw HTML that has already arrived, recognises URLs in tags it knows (img src and srcset, stylesheet links, script src, preload links) and starts those downloads. It builds nothing and runs nothing; it only gets the bytes moving.
That is why vendor.js, product.js and Copperpot's hero photo were all requested at about 320 ms in the sequence above, even though the parser did not reach them until much later. The scanner can only see what is written in the HTML, though. Anything that a script adds, anything named only inside CSS, and anything hidden in a data attribute for a lazy loader is invisible to it, and waits for the stage that does see it.
What the preload scanner can start early
Fonts do not block the paint, they hide the text
A web font is not render-blocking in the stylesheet sense. Its @font-face rule only says where the file is; the browser downloads it once styles show that text on the page actually uses it, which means after the CSS has been parsed and usually around the first layout. That makes fonts late by design, and the preload scanner cannot help, because the URL lives in CSS.
What happens to the text meanwhile is set by a timeline in CSS Fonts Level 4. During the block period, text that wants the font is laid out and drawn with an invisible fallback, so the space is there but the words are not. During the swap period it is drawn in a visible fallback and swapped when the font lands. After that comes the failure period: the fallback stays. Chromium-based browsers and Firefox use a block period of up to 3 s by default (font-display: auto behaves like block there). The font-display descriptor sets the two periods: block keeps that short block (about 3 s) and an unlimited swap period; swap has almost no block period and an unlimited swap period; fallback a block of about 100 ms and a swap of about 3 s; optional the tiny block and no swap period at all, so a font that misses it is not used on this page view. Which one to choose, and how to get the file earlier, are covered in Resource hints, priorities and fonts.
The same heading with the default and with swap
Scenario 1 of 2: As described.
Timeline as a list
The same heading with the default and with swap: 3 lanes, from 0 ms to 4,000 ms.
- 0–3,400 ms · Fraunces download · downloading
- 0–3,000 ms · Text with the default policy · invisible (block period)
- 0–3,400 ms · Text with font-display: swap · fallback font
- 3,000–3,400 ms · Text with the default policy · fallback
- 3,000 ms · Text with the default policy · block period ends (deadline)
- 3,400–4,000 ms · Text with the default policy · Fraunces
- 3,400–4,000 ms · Text with font-display: swap · Fraunces
- 3,400 ms · Text with font-display: swap · swap (lines may move)
Asking for a block on purpose
Sometimes the default is too lenient. A deferred or module script that sets a theme class, or picks the variant of an experiment, would cause a visible flash if the page painted before it ran. The blocking attribute with the value render, on a script, link or style element in the head, puts that element on the render-blocking list without making it parser-blocking. The parser keeps going; only the paint waits. It is supported in Chrome and Edge 105 and later and Safari 18.2 and later, and not in Firefox as of September 2026, where the page simply paints without waiting, so it must stay an improvement rather than a requirement.
Holding the paint, not the parser
<head>
<link rel="stylesheet" href="/css/main.css">
<!-- downloads in parallel and runs after parsing,
but no frame is painted until it has run -->
<script type="module" src="/js/theme.js" blocking="render"></script>
</head>
In practice.
Copperpot's head, rewritten tag by tag: what each change bought, where the first 1.22 s went, how to find blockers on a real page and how to talk about them in an interview.
Copperpot's head, before and after
<head>
<meta charset="utf-8">
<title>Enamel casserole, 24 cm · Copperpot</title>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/print.css" media="print">
<script src="https://consent.vendor.example/consent.js"></script>
<script>window.cpLayer = [{ page: "product" }];</script>
<script src="/js/vendor.js"></script>
<script src="/js/product.js"></script>
</head>
<body>
<img src="/img/casserole-teal-1200.avif" alt="…" width="1200" height="960">
…
<script async src="/js/reviews.js"></script>
</body>
<head>
<meta charset="utf-8">
<title>Enamel casserole, 24 cm · Copperpot</title>
<!-- reads no styles, so it goes before the stylesheet -->
<script>window.cpLayer = [{ page: "product" }];</script>
<link rel="preload" href="/fonts/fraunces.woff2" as="font"
type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/main.css"> <!-- font-display: swap -->
<link rel="stylesheet" href="/css/print.css" media="print">
<script async src="https://consent.vendor.example/consent.js"></script>
<script defer src="/js/vendor.js"></script>
<script defer src="/js/product.js"></script>
</head>
Copperpot's first load, before and after
Scenario 1 of 2: As described.
Timeline as a list
Copperpot's first load, before and after: 8 lanes, from 0 ms to 1,600 ms.
- 0–300 ms · HTML · server
- 300–420 ms · HTML · HTML
- 300–315 ms · Main thread ·
- 310–700 ms · main.css (48 KB) · download
- 315–615 ms · consent.js (new origin) · DNS + TCP + TLS
- 315–760 ms · Main thread · parser stopped at consent.js
- 320–900 ms · vendor.js · download (scanner)
- 320–920 ms · product.js · download (scanner)
- 330–1,100 ms · Hero photo (140 KB) · download
- 615–760 ms · consent.js (new origin) · download
- 760–812 ms · Main thread · consent
- 812–900 ms · Main thread ·
- 900–1,060 ms · Main thread · vendor.js
- 1,060–1,110 ms · Main thread · product
- 1,110–1,160 ms · Main thread · body
- 1,160–1,200 ms · Main thread · reviews
- 1,162 ms · Main thread · DOMContentLoaded
- 1,200–1,500 ms · Fraunces font · download
- 1,200–1,220 ms · Main thread ·
- 1,220 ms · all lanes · first paint (deadline)
- 1,510 ms · Fraunces font · text visible (ok)
Where the 1,220 ms before the first paint went
- TTFB, width 300, Server (time to first byte), value 300
- width 15, Parsing and drawing
- consent origin, width 300, Waiting on the network, value 300
- consent.js, width 145, Waiting on the network, value 145
- width 52, Running script, consent.js and the inline snippet
- width 88, Waiting on the network, vendor.js still downloading
- vendor.js, width 160, Running script, value 160
- width 50, Running script, product.js
- width 50, Parsing and drawing, parsing the body
- width 40, Running script, async reviews widget runs just before the frame
- width 20, Parsing and drawing, style, layout, paint
- parser held up: 795 ms, from 315 to 1,110
- Server (time to first byte)
- Waiting on the network
- Running script
- Parsing and drawing
- Gone after the fix
- cmp-run
- consent.js and the inline snippet
- vendor-wait
- vendor.js still downloading
- product-run
- product.js
- body
- parsing the body
- reviews-run
- async reviews widget runs just before the frame
- paint
- style, layout, paint
As it starts. 2 steps follow.
Copperpot's milestones before and after
- Before
- After
Data
| Milestone | Before (ms) | After (ms) |
|---|---|---|
| First paint | 1,220 | 540 |
| Text visible | 1,510 | 540 |
| Hero drawn | 1,220 | 910 |
| DOMContentLoaded | 1,162 | 1,115 |
| Basket wired | 1,110 | 1,110 |
Finding the blockers on a real page
Listing render-blocking resources from the page itself
// Run after load, or report it with your field metrics.
const blockers = performance
.getEntriesByType("resource")
.filter((e) => e.renderBlockingStatus === "blocking")
.map((e) => ({ url: e.name, doneAt: Math.round(e.responseEnd) }));
console.table(blockers);
// Copperpot before the fix: main.css 700, consent.js 760, vendor.js 900, product.js 920
Saying it in an interview
Where the systems' flows lean on this
Trade-offs.
Every wait you remove moves a risk somewhere else: a page that paints before its styles or scripts are ready can flash, shift or ignore a tap. Three choices, and how they go wrong.
- Pro:Downloads start with the head; the scanner is not even needed
- Pro:Never stops the parser
- Pro:Runs in order after the DOM is complete
- Pro:Sees every stylesheet's rules
- Con:Waits for parsing to finish
- Con:so a very long streamed page delays it
- Con:The page is visible before it is wired up
Order between files is not kept; May run before the DOM it needs exists; Can run right before the first frame and push it back
Download only starts early if the scanner reaches it; Still stops the parser and still waits for CSS; No benefit over defer today
Blocks parsing and; because no body exists yet; the first paint; Waits for every stylesheet above it
- Pro:Its new connection and download happen beside the parser
- Pro:An outage at the vendor cannot hold the page
- Con:Code that must wait for it (other tags gated on consent) needs a callback or event
- Con:It can still run at an awkward moment on the main thread
A cold visit pays the vendor's DNS lookup and connection set-up before the page can render; A slow or down vendor means a white page
Not acceptable for code that must act before other tags (consent); Data from early in the visit may be lost
- Pro:Simple and cacheable across pages
- Pro:No flash of unstyled content
- Pro:Print and wide-screen rules stop blocking phones
- Con:Rules for parts of the page far below the fold still block the first frame
Inlined bytes are not cached across pages; Getting the split wrong flashes unstyled content; Belongs to The critical rendering path topic
The first paint shows raw HTML and then jumps; Layout shifts hurt CLS
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| A third-party script in the head hangs (vendor outage, blocked domain)3Network service | The parser waits for it, so the page stays white until the browser gives up on the request, which can take many seconds | Field first paint jumps for every user at once while your own servers look healthy | Load it async, or self-host it, and never make your own code depend on its having run | With async, the page renders and only that vendor's feature is missing |
| Styles the first screen needs are injected by a script instead of linked in the head4Style engine | The first paint happens before the rules exist, then the page restyles and jumps (unstyled flash, layout shift) | CLS in field data, and a filmstrip that shows raw HTML in the first frame | Keep every rule the first screen needs in a parser-created stylesheet in the head | Content is readable but briefly unstyled |
| The page paints long before its scripts run5Script engine | Buttons and inputs look ready but ignore taps, or a tap is lost | Clicks recorded before the handlers attached; INP and rage-click signals in the first seconds | Make key actions work as plain links and forms, show a busy state, and keep the deferred bundle small | Actions fall back to a full page request |
| The web font is slow or fails6Renderer | With the default policy, text is invisible for up to 3 s in Chromium and Firefox, then shown in a fallback | A gap between first paint and when text appears in filmstrips; font request timing in Resource Timing | Preload the one font the first screen uses and choose a font-display that shows a fallback | Text in the fallback face, which may reflow when the font arrives |
| A script is moved from plain to async but depended on order1HTML parser | It runs before the library it uses, throws, and the feature is dead on some loads but not others | Errors in field monitoring that appear only on fast or cached loads | Use defer for anything that depends on another script; reserve async for independent code | The feature is missing until the next load wins the race |