The short answerThe page arrives as text, written by the server. The browser reads it from top to bottom and builds a tree of nodes, the DOM. Each line that names a file makes the browser ask for it, and those files ask for more, sometimes from another server: the developer tools call whoever asked the initiator, and the links form a request initiator chain. The style rules become the CSSOM, and the browser waits for the stylesheet before it paints anything. By default, a script stops the parser, unless it has defer. Then the browser works out where every box goes (layout), fills the boxes (paint) and puts the layers together (composite). After every change, it does part of that work again.
One address, eight requests
We built a tiny site for this episode. Its home page shows order 42, the order of episode 01 and episode 03, this time as a web page. We gave it a known name, like the addresses you type every day, youtube.com or github.com: a name, and / for the home page. Its address is https://mehditmimi.com/. The page is 15 lines of HTML. It names a stylesheet, a script and an image. The stylesheet names a background image and a font, and the script asks the server for the order’s data. Every file comes from mehditmimi.com, except the font, which comes from a second server, cdn.mehditmimi.com.
A demo, not this website. Two small Node.js servers on a laptop at home answered for the two names: Chromium was told to look for mehditmimi.com and cdn.mehditmimi.com on that laptop (its --host-resolver-rules option), so the real mehditmimi.com was never asked. Both servers speak HTTPS, with one self-signed certificate that Chromium was told to accept. Every capture below is labelled “demo site served as mehditmimi.com”.
We opened https://mehditmimi.com/ in Chromium 153, with an empty cache, and read two witnesses: the browser’s developer tools, and the servers’ own logs.
/ after its host: the page shows as “mehditmimi.com”, and the data (/api/orders/42) as “42”.09:40:58.454 GET / → 200 OK 09:40:58.462 GET /style.css → 200 OK 09:40:58.463 GET /img/notebook.png → 200 OK 09:40:58.464 GET /app.js → 200 OK 09:40:58.466 GET /img/paper.png → 200 OK 09:40:58.476 GET /api/orders/42 → 200 OK 09:40:58.485 GET /favicon.ico → 200 OK
09:40:58.479 GET /fonts/playfair-display-700.woff2 → 200 OK
Eight requests for one address, to two servers, all answered 200 OK, 150 kB in all, within 35 milliseconds on this home network. Seven went to mehditmimi.com, and one, the font, to cdn.mehditmimi.com. Counted another way, seven come from the page itself, as the rest of this page shows. The eighth, /favicon.ico, is the browser’s own: the page names no icon, so Chromium asked for the site’s default one, for the tab. Its Initiator column says Other, like the page’s own request.
The screenshot and the logs come from two loads of the same page, about half an hour apart (10:12 and 09:40 UTC); they show the same eight requests. In the logs’ run, with no DevTools window open, the last request ended 35 ms after the first began.
The page is text, and the DOM is a tree
The answer to GET / is an ordinary HTTP response. Its Content-Type says what the body is: HTML text, in UTF-8.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 357
Date: Sat, 26 Sep 2026 09:42:16 GMT
Connection: keep-alive
Keep-Alive: timeout=5
↵ empty line
⋯ the body: 15 lines of HTML, below with their numbers
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8"> 5 <title>Order 42</title> 6 <link rel="stylesheet" href="/style.css"> 7 <script src="/app.js" defer></script> 8 </head> 9 <body> 10 <p class="site">mehditmimi.com</p> 11 <h1>Order 42</h1> 12 <img src="/img/notebook.png" alt="A notebook" width="320" height="200"> 13 <p>1 notebook</p> 14 </body> 15 </html>
curl was pointed at the same laptop with its --connect-to option, and told to accept the self-signed certificate.
That body is only text. It is HTML, words wrapped in tags. The server wrote it, and the browser does what it says. Inside the browser, a parser reads it from top to bottom. In Chromium’s trace, the parser read all 15 lines in one pass that took 0.27 milliseconds.
Each opening tag becomes a node, called an element. An element that opens inside another becomes its child. The words also become nodes, text nodes. So the text grows into a tree of nodes: the DOM, the Document Object Model. The DOM Standard says each document “is represented as a node tree”, and everything that follows works on it: scripts read and change it, style rules are matched to it, and the pixels are drawn from it.
html lang="en" ├─ head │ ├─ meta charset="utf-8" │ ├─ title │ │ └─ text "Order 42" │ ├─ link rel="stylesheet" href="/style.css" │ └─ script src="/app.js" defer └─ body ├─ p class="site" │ └─ text "mehditmimi.com" ├─ h1 │ └─ text "Order 42" ├─ img src="/img/notebook.png" … └─ p └─ text "1 notebook"
The DOM also keeps the line breaks and spaces between the tags, as text nodes of whitespace: 11 of them in this page. DevTools’ Elements panel hides them, and so does this tree.
<p class="status">. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.The request initiator chain
While the parser reads, the browser meets lines that name other files. Line 6 names a stylesheet, and line 7 names a script. Further down, line 12 names an image. The browser asks the server for each one at once. The browser’s developer tools show who asked for each file, and they call that the initiator.
Those files ask for more. The stylesheet needs a font, and a background image: the stylesheet is their initiator. The page asks for the script, then line 3 of the script asks for the order’s data. Each request points to the one that caused it, and the links form a request initiator chain. In this page the chains make a tree of seven requests, with the browser’s own icon request beside it:
https://mehditmimi.com/initiator Other: the address you opened/style.cssinitiator (index):6: the page, line 6/img/paper.pnginitiator style.csshttps://cdn.mehditmimi.com/fonts/playfair-display-700.woff2initiator style.css, on the second server
/app.jsinitiator (index):7: the page, line 7/api/orders/42initiator app.js:3: the script, line 3
/img/notebook.pnginitiator (index):12: the page, line 12
/favicon.icoinitiator Other: the browser’s own, for the tab
| Request | Server | DevTools’ Initiator column | The page’s own view (initiatorType) | Sent after the page’s request |
|---|---|---|---|---|
/ | mehditmimi.com | Other | the navigation itself | 0 ms |
/style.css | mehditmimi.com | (index):6 | link | 8.3 ms |
/app.js | mehditmimi.com | (index):7 | script | 8.5 ms |
/img/notebook.png | mehditmimi.com | (index):12 | img | 8.6 ms |
/img/paper.png | mehditmimi.com | style.css | css | 14.3 ms |
/fonts/playfair-display-700.woff2 | cdn.mehditmimi.com | style.css | css | 14.5 ms |
/api/orders/42 | mehditmimi.com | app.js:3 | fetch | 24.2 ms |
/favicon.ico | mehditmimi.com | Other | other | 34.1 ms |
DevTools’ documentation lists who can start a request: “Parser. Chrome’s HTML parser”, “Script. A JavaScript function”, a redirect, and “Other. Some other process or action, such as navigating to a page using a link or entering a URL in the address bar”. The column also gives the file and the line, as a link: (index):6 is the page’s line 6, app.js:3 the script’s line 3. (index) is the name DevTools gave our page, whose address ends in /. The font and the background image are marked style.css, without a line.
In one of our runs, DevTools’ very first drawing of the table wrote two of these cells as mehditmimi.com/:6 and mehditmimi.com/:12; every redraw showed (index).
The page can read its own list too, with no developer tools: the browser’s Resource Timing interface gives each file an initiatorType. It says link, script and img for the three tags, fetch for the data, and css for the font and the background image. The standard is explicit: a font from @font-face “is a result of processing a CSS directive. Therefore, the initiatorType for this font resource is ‘css’.”
One page, two servers
A page does not need a single server. Ours used two. The HTML, the stylesheet, the script, the two images, the data and the icon came from mehditmimi.com; the font came from cdn.mehditmimi.com. The browser sends each request to the server that its address names, and each server answers the requests sent to it. Here the chain even crosses from one server to the other: a stylesheet on the first asked for a font on the second. DevTools shows it in two places, pictured below: the optional Domain column of the Network panel (right-click a column header, then Domain), and the font’s Initiator tab.
Real sites do this all the time. Many load their fonts from a font service, or their images, stylesheets and scripts from a CDN, a content delivery network: servers in many places that keep copies of a site’s files close to its visitors. This page itself gets its fonts from Google’s font servers.
One catch: a font needs the other server’s permission. Images, stylesheets and scripts from another server simply load (unless their tag asks for more, with a crossorigin attribute). A font from another origin does not. An origin is the start of an address: its scheme, host and port. So https://mehditmimi.com and https://cdn.mehditmimi.com are two origins.
CSS Fonts Level 4 fetches every font in CORS mode (CORS stands for Cross-Origin Resource Sharing). The request says which origin is asking, in an Origin header. The browser then uses the font only if the answer allows that origin, with an Access-Control-Allow-Origin header. Our second server answers Access-Control-Allow-Origin: *: any origin may use its font. The standard warns that fonts “will typically not be loaded cross-origin unless authors specifically take steps to permit cross-origin loads”. A fetch() call to another origin follows the same rule, since its mode is "cors" by default. Ours asked its own server, so it needed nothing.
We switched that header off on the second server and loaded the page again. The server still answered 200 OK and sent the font, but Chromium would not use it:
Access-Control-Allow-Origin · demo site served as mehditmimi.com Chromium 153 · Sep 26, 2026
Access to font at 'https://cdn.mehditmimi.com/fonts/playfair-display-700.woff2' from origin 'https://mehditmimi.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
The heading stayed in Georgia, the stand-in font. The server is still the boss: whether another origin may use its font is its own choice, and the browser respects it.
A second test: we moved the stylesheet itself to the second server, which then sent no CORS header at all. The page was still styled; the one difference was that a script on the page could no longer read that stylesheet’s rules (cssRules threw a SecurityError). The font was refused again: a font is asked for on behalf of the page’s origin, even when its stylesheet lives elsewhere. Chromium 153, September 26, 2026 (09:41 UTC), demo site served as mehditmimi.com.
Three ways to see the chain in DevTools
1. The Initiator column, in the Network panel above: each row names its initiator.
2. Shift and hover. In DevTools’ words: “hold Shift and hover over the request … DevTools colors initiators green, and dependencies red.”
style.css: its initiator, the page, turns green; its two dependencies, the background image and the font, turn red. The Domain column, switched on here, shows where each file came from: cdn.mehditmimi.com for the font, mehditmimi.com for all the rest. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.3. The Initiator tab. Click a request, then open its Initiator tab. For a request made by a script, it shows the call that made it (“Request call stack”) and the whole chain above it (“Request initiator chain”):
app.js, then /api/orders/42, made by line 3 of the script. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.
style.css on mehditmimi.com, then the font on cdn.mehditmimi.com. DevTools writes the full addresses; nothing else marks the change of server. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.A fourth view, in the Performance panel. After “Record and reload”, its Network dependency tree insight draws the longest chain, the one the page waited for, and gives its time. Here: the page, then style.css, then the font on the second server, with a “Max critical path latency” of 30.52 ms. It is DevTools’ own name for the same idea: “Avoid chaining critical requests”.
Other: requests the page did not ask for
Two rows say Other. The first is the page itself: you asked for it, by typing its address (in our test, a script drove the browser there). The second, /favicon.ico, is the browser’s idea. The HTML Standard allows it: “In the absence of a link with the icon keyword”, a browser “may” fetch /favicon.ico from the page’s site, to show in the tab. “May” means another browser, or the same browser next time, might not ask. Chromium did.
What the server sees
The servers never see the initiator. Each request after the first did carry a hint, though: the Referer header, the address of a document that led to it.
Referer header (and the font’s Origin) · Chromium 153 · demo site served as mehditmimi.com Sep 26, 2026mehditmimi.com’s log GET /style.css Referer: https://mehditmimi.com/ GET /img/notebook.png Referer: https://mehditmimi.com/ GET /app.js Referer: https://mehditmimi.com/ GET /img/paper.png Referer: https://mehditmimi.com/style.css GET /api/orders/42 Referer: https://mehditmimi.com/ GET /favicon.ico Referer: https://mehditmimi.com/ cdn.mehditmimi.com’s log GET /fonts/playfair-display-700.woff2 Referer: https://mehditmimi.com/ Origin: https://mehditmimi.com
The background image arrived with the stylesheet’s address. The data arrived with the page’s address, not the script’s: app.js runs inside the page, and its request carried the page’s address. The font, on the other server, arrived with the site’s address only, https://mehditmimi.com/: when a request goes to another origin, Chromium’s default referrer policy leaves out the path. It also carried the Origin header of a CORS request. So a server can rebuild part of the chain from its own log, but not all of it. The server is still the boss: it wrote the page, the stylesheet and the script, so it decided which files the browser would ask for, all but the icon.
Real pages grow bigger trees
Our page is tiny on purpose. We opened four public pages the same day, once each, in a fresh Chromium profile, and counted the requests made up to five seconds after each one had loaded. One of them is the real mehditmimi.com, this website, not our demo:
| Page | Requests | Links in the longest chain below the page |
|---|---|---|
example.com | 1 | 0 |
mehditmimi.com, the real home page (this website) | 11 | 2 |
| Wikipedia, the “Web browser” article | 47 | 3 |
| MDN, “How browsers work” | 78 | 3 |
| This episode’s demo page | 8 | 2 |
Chromium 153, September 26, 2026, one visit each (the public pages at 00:11 UTC; the demo page, served as mehditmimi.com, at 09:40 UTC). These counts change from one visit to the next, with the cache, the network and what each page decides to load.
Fonts and images come from the CSS, and only when used
The stylesheet declares its font with @font-face and points to its background image with url(). The font’s address names the second server; the image’s, a path on the first:
1 @font-face { 2 font-family: "Playfair Display"; 3 src: url("https://cdn.mehditmimi.com/fonts/playfair-display-700.woff2") format("woff2"); 4 font-weight: 700; 5 font-display: swap; 6 } ⋯ 8 html { 9 background: #efe7d8 url("/img/paper.png"); 10 } ⋯ 32 h1 { 33 margin: 0 0 18px; 34 font: 700 48px/1.1 "Playfair Display", Georgia, serif; 35 }
The browser asks for such files only when a rule that applies to the page needs them. CSS Fonts Level 4 makes it a rule for fonts: “user agents must only download those fonts that are referred to within the style rules applicable to a given page”. In Chromium’s trace, the stylesheet arrived 16.75 ms after the navigation began. The browser matched its rules to the DOM at 18.29 ms, then asked for the paper texture at 18.48 ms and the font, from the second server, at 18.67 ms: the html rule uses one, the h1 rule the other.
Our stylesheet says font-display: swap. Until the font arrives, the text is drawn in a stand-in font, the next one in the list (Georgia), and then swapped. CSS Fonts 4 describes swap as “an extremely small block period” and “an infinite swap period”. Even with nothing slowed down, the first frame that showed the page still had the stand-in:
Two frames of the trace’s own filmstrip (DevTools’ screenshots), Chromium 153, September 26, 2026, demo site served as mehditmimi.com, times from the start of the navigation. A frame in between, at 39 ms, only added the paper texture. The swap changed the heading’s width, so the text under it moved a little: Chromium recorded a layout shift of 0.00036, a very small one.
The CSSOM, and the cascade
The stylesheet is a list of rules. Each rule picks some elements, with its selector, and gives them styles. The browser also turns these rules into objects: the CSSOM, the CSS Object Model, whose standard “defines APIs (including generic parsing and serialization rules) for Media Queries, Selectors, and of course CSS itself”. It is a list of rule objects, not a second tree shaped like the page. Then the browser matches the rules to the DOM, and every element gets its final style.
The page’s rules are not the only ones. The browser has its own, the user agent stylesheet, and when two rules set the same property the cascade decides which wins. DevTools shows it in the Styles pane:
<h1>. The page’s rule (style.css:32) wins over the browser’s own h1 rule, whose font-size and font-weight are struck through. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.Why the browser waits for the stylesheet
To see the browser wait, we slowed the servers down on purpose, to separate the stages: mehditmimi.com held style.css back for one second and the data for one and a half, and cdn.mehditmimi.com held the font for one second. At 513 ms, the DOM was complete, all 11 elements, and the page had applied no stylesheet and painted nothing. The stylesheet arrived at 1,026 ms, and the first paint came right after, at 1,044 ms:
1 msBlank, and nothing new is shown until 1,043 ms.
1,043 msThe first frame: styled, in the stand-in font.
2,060 msThe font has arrived.
2,545 msThe data has arrived: the script’s line.
DevTools’ filmstrip of that load: the trace’s own screenshots, Chromium 153, September 26, 2026, demo site served as mehditmimi.com. Delays added on purpose by the servers: style.css 1 s and the data 1.5 s on mehditmimi.com, the font 1 s on cdn.mehditmimi.com.
This wait is in the standard. The HTML Standard says a stylesheet link “is implicitly potentially render-blocking if the element was created by its node document’s parser”. While such a stylesheet is pending, the document is “render-blocked”, and the step that updates the screen skips it: “Remove from docs any Document object doc for which … doc is render-blocked”. The standard also allows an “implementation-defined timeout”, so a browser does not wait forever.
Why wait? Without the wait, you would first see the page in the browser’s default styles. Then everything would jump, in what web developers call a flash of unstyled content. Here is the same page with an empty stylesheet, next to what the browser painted first:
So the first thing the browser paints is the styled page. The browser itself says which file held the paint back. The page’s own Resource Timing marks style.css with renderBlockingStatus: "blocking", and every other file "non-blocking". The Performance panel names it too:
/style.css. Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.Images, fonts, the deferred script and the data did not hold the first paint back. In the slowed load the font arrived at 2,056 ms, a second after the first paint, and in a load where only the script was late, the page was painted at 32 ms (see below).
Chromium may still work on the unstyled page while it waits: in one of our runs it computed styles and layout for it, then painted nothing from them.
We checked it in three browsers
Does every browser wait for the stylesheet?
Yes, for a stylesheet the parser meets in the head, like ours. We held style.css back one second, then ten, in Chromium 153, Firefox 155 and WebKit 26.6, the engine behind Safari. At 500 ms, all three had the whole DOM and had drawn nothing. Their first paint came just after the stylesheet, between 1,022 and 1,044 ms, and it was already the styled page. At ten seconds, none of them gave up and painted the page unstyled. A flash of unstyled content still happens today, but in other cases:
| The stylesheet | Chromium 153 | Firefox 155 | WebKit 26.6 |
|---|---|---|---|
| Linked in the head, as on our page | waits first paint at 1,044 ms, styled | waits 1,040 ms, styled | waits 1,022 ms, styled |
| Linked in the head, 10 s late | waits 10,044 ms, styled | waits 10,043 ms, styled | waits 10,017 ms, styled |
| Added to the head by a script | flash unstyled at 21 ms, styled at 1,046 ms | flash unstyled at 66 ms, styled at 1,085 ms | waits 1,020 ms |
| Linked in the body, after the heading | flash the heading only, above the link, unstyled at 13 ms; the rest waits | flash the whole page unstyled at 59 ms, styled at 1,085 ms | waits 1,018 ms |
Linked in the head, and a script asks for a size (offsetHeight) at 300 ms | waits 1,034 ms | flash unstyled at 349 ms, styled at 1,123 ms, with a warning in the console | waits 1,022 ms |
WebKit was the strictest: it also waited for the stylesheet added by a script and for the one in the body, which the standard does not ask for. Firefox named its own flash in the console: “Layout was forced before the page was fully loaded. If stylesheets are not yet loaded this may cause a flash of unstyled content.”
How we tested, September 26, 2026 (09:44 to 09:54 UTC): a copy of this episode’s first test page, with the same head (the stylesheet on line 6, the script on line 7), served by a small local test server that held the file back on purpose. Four loads per case and browser (two for the ten-second case), each in a fresh browser context, driven by Playwright 1.63.0 on Windows 11. “First paint” is each browser’s own Paint Timing (a median); the other times are frames of each browser’s own screencast. WebKit 26.6 is Playwright’s build of Safari’s engine, not Safari itself.
Did old browsers show the page first?
Many developers remember old browsers showing a page unstyled, then styling it when the CSS came. For a stylesheet linked in the head, the record says otherwise: the main desktop browsers waited too, or showed a blank white page, and none is documented to give up and paint the page unstyled. The flashes that gave the problem its name came from particular cases:
- Internet Explorer and
@import. The name “Flash of Unstyled Content (FOUC)” was coined in 2001 for “a curious display quirk in the Windows version of MS Internet Explorer” (version 5 and later), on pages that loaded their stylesheet with@import(BlueRobot, archived copy). - Stylesheets at the bottom of the page. Steve Souders, at Yahoo! in 2007: there, Internet Explorer left “a blank white page”, and Firefox showed the flash (“Put CSS at the Top”, archived copy).
- Scripts that ask for layout before the CSS has come. Safari then laid out and showed what it had. Dave Hyatt, of the WebKit team, wrote in 2006 that “FOUC has become a Safari epidemic”, and that WebKit’s nightly builds now suppressed it (“The FOUC Problem”). Firefox still flashes in that case, and since Firefox 76 (2020) it warns in its console (Mozilla bug 1623968): the warning in the last row of our table.
- Opera, on purpose. Its chief technology officer, Håkon Wium Lie, wrote in 2008 that “Opera supports progressive rendering of content as style sheets are loaded” (www-style mailing list).
What is recent is the standard. Until February 2022, the HTML Standard did not say that rendering waits for stylesheets: browsers did it without a written rule. The render-blocking text quoted above was added then, “to specify cases where <script> and <link> can be render-blocking in all browsers, but were not according to the spec” (whatwg/html pull request 7474).
Scripts: by default, the parser waits
By default, a script stops the parser. The browser downloads the script, runs it, and only then reads on. Why? A script might write new HTML right where it stands. An old method, document.write(), does exactly that: the HTML Standard says the text the parser reads usually comes from the network, “but it can also come from script running in the user agent, e.g. using the document.write() API”. What the script writes goes in just after its own tag, so the parser cannot know what comes next until the script has run. The Standard now strongly discourages document.write(), but a browser cannot tell in advance whether a script uses it.
The Standard calls such a script a classic script (one that is not a module). With a src and neither defer nor async, it is “fetched and evaluated immediately, blocking parsing until these are both complete”: “the blocking behavior that is the default”.
We tested it with our own script, app.js:
1 const body = document.body; 2 3 fetch('/api/orders/42') 4 .then((response) => response.json()) 5 .then((order) => { 6 const line = document.createElement('p'); 7 line.className = 'status'; 8 line.textContent = `Status: ${order.status}`; 9 body.append(line); 10 });
The server held app.js back for one second, on purpose, and we loaded the page twice: once with line 7 as <script src="/app.js"></script>, the default, and once as written, with defer. Chromium’s trace recorded:
| What happened | By default | With defer |
|---|---|---|
| The parser reads | lines 1 to 7 at 10.94 ms, then stops | the whole page at 12.05 ms, in one pass |
app.js arrives, and runs | 1,027.95 ms, and 1,028.02 ms | 1,024.23 ms, and 1,024.31 ms |
| The parser reads on from line 7 | at 1,028.43 ms: it waited about one second at line 7 | already done |
| First paint | 1,049.38 ms | 31.64 ms |
DOMContentLoaded | 1,037.63 ms | 1,024.69 ms, after the script |
| The status line | never shown: the script failed | added |
defer · demo site served as mehditmimi.com Chromium 153 · Sep 26, 2026Uncaught (in promise) TypeError: Cannot read properties of null (reading 'append')
By default, the script ran before the rest of the page existed. At 503 ms, while the parser waited, the page had six elements: html, head, meta, title, link and script, and no body. So on line 1, document.body gave null; when the data came, line 9 failed, and the status line was never added. With defer, the script ran once the tree was complete, and it worked.
Even without the delay, it fails the same way. The one-second wait only makes the problem long enough to see. With app.js sent at once, the script failed in all 12 of our loads, four each in Chromium 153, Firefox 155 and WebKit 26.6: a script in the head runs before the body exists.
That check, on September 26, 2026, used a copy of this episode’s first test page, whose script looked for a <main> element rather than the body; neither existed yet when the script ran. Each browser words the error its own way: Firefox said can't access property "append", main is null.
With defer, the page appeared after about thirty milliseconds, not after a second. The script downloaded while the parser read on, then ran when parsing was done. DOMContentLoaded waited for it, as MDN notes: “Scripts with the defer attribute will prevent the DOMContentLoaded event from firing until the script has loaded and finished evaluating.” The page was on screen a second before that event.
Five more loads of the unslowed page painted first between 28 and 36 ms. The film rounds the 31.64 ms of this run to “about thirty milliseconds”; its screen shows the marker, 32 ms.
| The tag | Download | Runs |
|---|---|---|
<script src> | when the parser reaches it, and parsing stops | at once, then parsing continues: “blocking parsing until these are both complete” |
<script src defer> | “in parallel”, while parsing continues | “when the page has finished parsing”, in the order of the page, before DOMContentLoaded |
<script src async> | “in parallel to parsing” | “as soon as it is available (potentially before parsing completes)” |
<script type="module"> | “in parallel to parsing”, with its imports | “when the page has finished parsing”, like defer; with async, as soon as it is available |
async and module scripts do not stop the parser either. We measured them too, in Chromium 153, Firefox 155 and WebKit 26.6, with app.js held back one second: the page’s tree was complete within 10 ms, as with defer. So the rule is about the default: a plain <script src>, with no defer, no async and no type="module".
A script written inside the page (<script>…</script>, with no src) runs as soon as the parser reaches it; defer has no effect on it (MDN: “This attribute must not be used if the src attribute is absent”).
A deferred script also waits for the stylesheet. In the slowed load of the section above, app.js arrived after 15 ms but ran only at 1,036 ms, right after style.css (1,026 ms). The HTML Standard’s last parsing step, “the end”, waits until the first deferred script is ready “and the parser’s Document has no style sheet that is blocking scripts”. That is why the data, which the script asks for, left late in that load. Firefox 155 waited the same way in our three-browser check; WebKit 26.6 ran the deferred script at once.
The preload scanner: the browser reads ahead
When the parser stopped at line 7, the image on line 12 was requested anyway, at 9.36 ms, a second before the parser got there (1,028.43 ms). Chromium has a second, lighter HTML reader for this, the preload scanner, “a secondary HTML parser” in web.dev’s words. It looks ahead for files to request while the main parser is blocked. It only fetches: it does not build the DOM, so the TypeError above happened anyway. DevTools still names the page and its line as the initiator ((index):12).
The HTML Standard allows this “speculative” reading, on one condition: “The state of the normal HTML parser and the document itself must not be affected.” We checked it in the three browsers, with an image and a second script added at the end of the page, and app.js held back one second. While the parser waited, Chromium and WebKit asked for all the later files within about 10 ms; Firefox asked for the later script at once (18 to 23 ms), and for the images only once its parser had moved on. In all three, none of these files entered the DOM early: the tree stayed stopped at the script tag, and the second script ran only after app.js.
From the tree to pixels
To show the page, the browser turns the tree into pixels. Chromium’s rendering pipeline, RenderingNG, names its stages: “Style: apply CSS to the DOM, and create computed styles. Layout: determine the size and position of DOM elements on the screen”, then pre-paint, “Paint: compute a display list”, then commit, layerize, raster, and finally “Draw: execute the aggregated compositor frame on the GPU to create pixels on-screen”. The film keeps the three that web.dev also teaches, after style: “Layout > Paint > Composite”.
- Layout works out the size and position of every box.
- Paint fills each box with color and text: in Chromium, it records the drawing commands, and raster turns them into pixels.
- Composite stacks the painted layers, like sheets of glass, into one frame.
Not every node gets a box. web.dev: “Some nodes are not visible (for example, script tags, meta tags, and so on), and are omitted”, and so is any element with display: none. In our page, the head and everything in it get no box. This is layout’s answer for the card and what is in it, in CSS pixels, in an 800 × 600 window:
| Element | Position (x, y) | Size (width × height) |
|---|---|---|
body, the card | 164, 40 | 472 × 500.3 |
p.site, the site name | 200, 68 | 400 × 19.5 |
h1 | 200, 95.5 | 400 × 52.8 |
img | 200, 166.3 | 400 × 250 |
p, “1 notebook” | 200, 432.3 | 400 × 30 |
p.status, added by the script | 200, 478.3 | 400 × 30 |
| Step, as DevTools names it | Time | What it did here |
|---|---|---|
| Recalculate style | 0.53 ms | recalculated the style of 6 elements |
| Layout | 6.84 ms | laid out 10 of 10 objects |
| Pre-paint | 0.07 ms | prepared the paint |
| Paint | 0.14 ms | recorded the drawing commands |
| Layerize and Commit | 0.04 + 0.06 ms | handed the layers to the compositor |
These are the main thread’s steps only. Raster and draw happen on other threads and in the GPU process. Our page painted into a single layer: Chromium’s layer tree held five layers, and one of them drew the page’s content. The compositor then joined it with the rest of the browser’s window. Chromium’s documentation says what layers are for: layerize “break[s] up the display list into a composited layer list for independent rasterization and animation”.
When the page changes
The work does not stop at the first frame. In the slowed load, when the font arrived at 2,056 ms, the heading changed shape, so Chromium ran layout again (5 of 10 objects, 1.12 ms), then paint. When the data came at 2,539 ms and the script added its element, layout ran again for 5 of 12 objects. JavaScript can change the DOM at any time, and each change asks for part of this work again.
We then changed the finished page three times from a script, one change per frame, and read the trace:
| The change | Steps Chromium ran | Layout |
|---|---|---|
The heading’s color becomes crimson | style, pre-paint, paint, layerize, commit | skipped |
The card’s (the body’s) max-width becomes 460px | style, layout, pre-paint, paint, layerize, commit | 3 of 12 objects |
The image gets transform: rotate(-3deg) | style, layout, pre-paint, paint, layerize, commit | 4 of 12 objects |
A new color skips layout. A new size needs layout, then paint. A color changes no box, so there is nothing to measure again. web.dev lists such “paint-only” properties, “such as background-image, color, or box-shadow”. A width changes boxes, so their neighbors are laid out again.
The transform surprised us: it went through layout too. That image had no transform before, and our change was a one-off, not an animation. Chromium’s documentation says “animations of visual effects, and scroll, can skip layout, pre-paint and paint”. We did not test an animation, so this page makes no claim about it. Every change repeats some of this work; which part depends on the change.
What the film simplified
- “The browser asks the server for each one at once”: Chromium’s preload scanner spotted lines 6, 7 and 12 first (the requests left at 8.57 to 8.76 ms, the main parser ran at 10.24 ms). DevTools still names the parser and the line as their initiator.
- “That body is only text”: bytes, which the browser decodes as UTF-8 because
Content-Typesays so. - “Each opening tag becomes a node”: the doctype, comments and the parser’s repairs of broken HTML are left out, and so are the 11 text nodes of whitespace.
- “The stylesheet needs a font”: it asks for the font only because a rule the page uses, the
h1rule, names it. - “A page can load its files from several servers”: images, stylesheets and scripts from another server simply load. A font, or a
fetch()call, from another origin is used only if that server allows it, withAccess-Control-Allow-Origin; our second server sends*. - The site’s name: the film’s
mehditmimi.comandcdn.mehditmimi.comare a demo, served by two small servers on a laptop; the real mehditmimi.com was never asked. - “The browser waits for the stylesheet before it paints”: for a stylesheet
linkthe parser met in the head, as here. Chromium 153, Firefox 155 and WebKit 26.6 all waited, even ten seconds. The standard lets a browser give up after a timeout; none did. - “Let’s load the page again”: each test was a separate visit in a fresh browser context (empty cache, new connections), not a reload.
- “So the first thing the browser paints is the styled page”: the first paint, as the browser’s Paint Timing counts it. Before it, the trace also records tiny paints of the tab’s blank page, which draw only white.
- “By default, a script stops the parser”: an external script with neither
defer,asyncnortype="module";asyncand module scripts do not stop it. Such a script also waits for stylesheets that are still loading. - “It might write new HTML right where it stands”: with
document.write(). Our script writes nothing, yet the parser waited: it cannot know what a script will do before the script has run. - “Even without the delay, it fails the same way”: measured in Chromium 153, Firefox 155 and WebKit 26.6, 12 loads out of 12, on a copy of the episode’s first test page.
- “It runs once the tree is complete”: a deferred script also waits for pending stylesheets, as the slowed load shows (in Chromium and Firefox; WebKit 26.6 ran it at once).
- “About thirty milliseconds”: 31.64 ms in the traced run (the film shows 32 ms), 28 to 36 ms in five more.
- “Paint fills each box”: Chromium’s paint records drawing commands; raster fills the pixels, on other threads.
- “Composite stacks the painted layers”: our page painted into one layer, which the compositor joined with the browser’s window.
- The times are those of a laptop talking to itself over its home network: real, and much faster than the internet. The one-second waits were added on purpose, as the film says.
- “You open one address”: in our tests, a script drove Chromium to the address; the typing in the film is an illustration.
Try it yourself
Find the chain on a real page
In Chrome, Edge or another Chromium browser. Press F12 (on a Mac: Cmd+Option+I) to open the developer tools. They can also be found in the browser’s menu, under More tools › Developer tools.
A. The Network panel, on this page
- Open the Network tab, then reload the page (Ctrl+R, or Cmd+R on a Mac). The status bar at the bottom gives the number of requests.
- Read the Initiator column. This page asks for its stylesheets and its script: the Initiator column shows the page and a line. The font files are asked for by Google’s font stylesheet, which this page links. Most pictures further down say Other: they load lazily, when you scroll near them, so the browser starts them itself.
- Right-click a column header and tick Domain. This page uses several servers: its own files come from mehditmimi.com, Google’s font stylesheet from fonts.googleapis.com, and the font files from fonts.gstatic.com. Click a font file, then Headers: its answer carries
access-control-allow-origin: *, the same permission as our demo’s second server. - Hold Shift and hover over a font file: its initiator turns green.
- Click a font file, then its Initiator tab: the request initiator chain, from this page to the font.
B. The Performance panel
- Open the Performance tab and click Record and reload (the round arrow). Keep Screenshots ticked.
- In the Insights list, open Render-blocking requests, then Network dependency tree.
- In the Main track, find Parse HTML, Evaluate script, Recalculate style, Layout and Paint.
C. Slow it down
- In the Network panel, change No throttling to Slow 4G. DevTools’ documentation: “To emulate fast 4G, slow 4G, or 3G, select the corresponding preset from the Throttling drop-down menu”.
- Record and reload again in the Performance panel. The screenshots now show the page appearing in stages. Set the throttling back to No throttling afterwards.
Quick check
Pick an answer, then see why
1. In the Network panel, the font’s Initiator cell reads style.css. What does that tell you?
Show the answer
B. The stylesheet’s @font-face rule names the font, and the browser asked for it when a rule the page uses needed it. The font was asked for only after the stylesheet had arrived (18.67 ms against 16.75 ms), and from another server, cdn.mehditmimi.com: an initiator can send the browser to any server.
2. The HTML has arrived, but the server holds the stylesheet back for one second. What is on screen during that second?
Show the answer
B. A stylesheet linked in the head is render-blocking: the DOM was complete at 513 ms, yet nothing was painted until 1,044 ms, right after the stylesheet. That wait prevents the flash of unstyled content (A). Firefox 155 and WebKit 26.6 waited too.
3. A script without defer sits in the <head>, and its first line is const body = document.body;. What does body get?
Show the answer
B. The parser stops at the script, so the rest of the page does not exist yet. Our app.js, loaded that way, got null and later failed with Cannot read properties of null (reading 'append'). It fails even when the script arrives at once. With defer, it runs once the tree is complete.
4. After the load, a script changes the heading’s color. Which step does Chromium skip?
Show the answer
A. A color changes no size or position. The trace shows style, pre-paint, paint, layerize and commit, and no layout. A new width, by contrast, laid out 3 of the page’s 12 objects again.
5. The font’s server, cdn.mehditmimi.com, stops sending Access-Control-Allow-Origin. What happens?
Show the answer
C. A font from another origin is fetched in CORS mode. In our test, the second server still answered 200 OK with the font, and Chromium blocked it (“has been blocked by CORS policy”); the heading stayed in Georgia. A stylesheet, an image or a script would have loaded (A is true for them, not for fonts).
Common mistakes
- A script in the head that reads the page. Without
defer, it runs before the body exists. Adddefer, or wait forDOMContentLoaded. Tested on September 26, 2026: ourapp.js, loaded withoutdefer, gotnullfordocument.bodyand failed with a TypeError; the status line never appeared. With the script sent at once, it failed the same way in Chromium, Firefox and WebKit. - Thinking a stylesheet never blocks anything. A stylesheet in the head holds back the first paint, and the deferred scripts behind it. With
style.cssheld back one second, the first paint came at 1,044 ms, andapp.js, which had arrived at 15 ms, ran at 1,036 ms. - Thinking
defermakes a script download faster. It changes when the script runs, not how fast it arrives. In both of our loads,app.jsarrived after the same one-second wait (1,024.23 and 1,027.95 ms); only the parser and the first paint changed. - Making every script
async. Anasyncscript runs “as soon as it is available”, so a script that needs another one may run first.deferkeeps the page’s order. The HTML Standard, and MDN: “Scripts with the defer attribute will execute in the order in which they appear in the document.” - Moving the fonts to another server and forgetting its permission. A font from another origin is used only if its answer carries
Access-Control-Allow-Origin. Images, stylesheets and scripts do not need it. Our second server without the header:200 OKin its log, “has been blocked by CORS policy” in Chromium’s console, and the heading left in Georgia. - Believing that old browsers always showed the page unstyled first. For a stylesheet linked in the head, they mostly waited too, or showed a blank white page. The flashes came from particular cases:
@importin Internet Explorer, stylesheets at the bottom of the page, scripts asking for layout early, and Opera, on purpose. Sources in the history above. A flash still happens today in some cases: a stylesheet added by a script flashed in Chromium 153 and Firefox 155.
English → French glossary
| English | Français |
|---|---|
| parser | analyseur (parseur) |
| DOM tree; node; element | arbre DOM ; nœud ; élément |
| stylesheet; style rule | feuille de style ; règle de style |
| developer tools | outils de développement |
| initiator; request initiator chain | initiateur ; chaîne d’initiateurs d’une requête |
| origin; cross-origin | origine ; d’une autre origine (cross-origin) |
| CORS (Cross-Origin Resource Sharing) | partage de ressources entre origines (CORS) |
| content delivery network (CDN) | réseau de diffusion de contenu (CDN) |
| render-blocking | bloquant pour le rendu |
| flash of unstyled content (FOUC) | flash de contenu sans style (FOUC) |
| preload scanner | analyseur de préchargement (preload scanner) |
| to defer a script | différer un script |
| layout; paint; compositing | mise en page ; peinture ; composition |
| layer | calque |
Let’s recap
- The server sends text, and the browser builds the DOM from it.
- The page asks for files, and those files ask for more.
- The developer tools show the initiator chain of every request.
- The style rules become the CSSOM, matched to the DOM.
- The browser waits for the stylesheet before it paints.
- By default, a script stops the parser, unless it has defer.
- JavaScript can change the DOM at any time.
- After layout places the boxes, paint and composite make the pixels.
- And any change can repeat these steps.
Each point links to its section, if you want to read it again.
Before and after
In the course, the page’s own language comes next: module 1, HTML Essentials.
Sources and credits
- The HTML Standard (WHATWG): the overview of the parsing model, tree construction, “the end” of parsing and speculative HTML parsing;
document.write(); thescriptelement,deferandasyncand the pending parsing-blocking script; thestylesheetlink type, implicitly potentially render-blocking; the render-blocking mechanism; update the rendering; CORS settings attributes and the potential-CORS request (no CORS without acrossoriginattribute); theiconlink type and the/favicon.icodefault. - The DOM Standard: node trees. CSS Object Model (CSSOM). CSS Fonts Level 4: font loading, font fetching requirements (CORS mode), and
font-display. The Fetch Standard: a request’s initiator type, the CORS protocol and its examples (Origin,Access-Control-Allow-Origin), and theRequestconstructor (fetch()’s default mode,"cors"). Resource Timing:initiatorType, and"css"for fonts. - Chrome for Developers: Network features reference (initiators and dependencies, the Initiator column, throttling); the Performance insights Network dependency tree and Render-blocking requests; RenderingNG architecture (the rendering pipeline). web.dev: render-tree construction, layout, and paint, render-blocking CSS, the pixel pipeline, the preload scanner. MDN: the
<script>element, speculative parsing. All read on September 26, 2026. - The history of the flash of unstyled content: BlueRobot, “Flash of Unstyled Content (FOUC)” (2001, archived copy); Steve Souders, Yahoo!, “Thirteen Simple Rules for Speeding Up Your Web Site”, rule 5 (2007, archived copy); Dave Hyatt, “The FOUC Problem” (WebKit blog, 2006); Mozilla bug 1623968 (Firefox 76, 2020); Håkon Wium Lie on the www-style mailing list (2008); whatwg/html pull request 7474 (merged February 10, 2022). Read on September 26, 2026.
- The demo’s captures were made on September 26, 2026, between 09:40 and 10:12 UTC, on a laptop at home (Windows 11). The demo site was served as
https://mehditmimi.com/, with its font onhttps://cdn.mehditmimi.com/, by two small Node.js 22 servers on that laptop, with one self-signed certificate. Chromium was told to find both names there (--host-resolver-rules), and curl the same (--connect-to), so the real mehditmimi.com was never asked. Tools: Chromium 153.0.8010.12, driven by Playwright 1.63.0, and the DevTools bundled with it;curl.exe8.21.0. The one-second waits were added on purpose by the servers. The public pages were opened the same day (00:11 UTC). - The three-browser check was run on September 26, 2026, between 09:44 and 09:54 UTC: Chromium 153.0.8010.12, Firefox 155.0 and WebKit 26.6 (Playwright’s builds; WebKit is the engine behind Safari, not Safari itself), driven by Playwright 1.63.0, against a copy of this episode’s first test page on a local test server. The course version of this topic: module 0, The Web Trio: HTML, CSS & JavaScript.
- The demo page’s heading uses Playfair Display (SIL Open Font License 1.1), served by its second server. Its notebook picture and paper texture were drawn for this episode.
- The film is animated in code with Remotion. Its narration is a synthetic voice, designed for this series with Qwen3-TTS. The script, the film and this page were prepared with AI assistance.