TMIMI Explains

Explains Web Foundations 04 · How browsers render

What does a browser do with the page it receives?

You open one address, and your browser sends eight requests. A moment later, the page is on your screen. Here is what happens in between: the tree the browser builds, the chain of files that ask for more files, the stylesheet it waits for, the script that can stop it, and the way to pixels.

Poster of the episode 04 film: What does a browser do with the page it receives?

Film coming soon

Short film · 5:15 · English narration (synthetic voice) · captions Watch on YouTube Everything in the film is on this page.

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.

The Network panel of Chrome DevTools after loading https://mehditmimi.com/. Eight rows: mehditmimi.com (document, initiator Other), style.css (stylesheet, (index):6), app.js (script, (index):7), notebook.png (png, (index):12), 42 (fetch, app.js:3), paper.png (png, style.css), playfair-display-700.woff2 (font, style.css), favicon.ico (x-icon, Other). Status bar: 8 requests, 150 kB transferred.
The Network panel after one load. Chrome DevTools, the version bundled with Chromium 153, September 26, 2026 (10:12 UTC), demo site served as mehditmimi.com. The empty rows between the table and its status bar are cut out. DevTools names each request after the last part of its address, and a page at / after its host: the page shows as “mehditmimi.com”, and the data (/api/orders/42) as “42”.
mehditmimi.com’s log · every request it received during one load · Chromium 153 · demo site served as mehditmimi.com Sep 26, 2026 · UTC
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
cdn.mehditmimi.com’s log · the same load, on the second server Sep 26, 2026 · UTC
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.

GET https://mehditmimi.com/ · the answer, printed by curl.exe 8.21.0 · demo site served as mehditmimi.com captured Sep 26, 2026
              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
            
The body · the home page, 357 bytes, with line numbers · demo site served as mehditmimi.com Sep 26, 2026
 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.

The DOM as parsed · 11 elements, with their text nodes · Chromium 153 · demo site served as mehditmimi.com Sep 26, 2026
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.

The Elements panel of Chrome DevTools showing the page's DOM tree: html, head with meta, title Order 42, link and script, then body with p class site mehditmimi.com, h1 Order 42 (selected), img, p 1 notebook, and p class status with Status: shipped.
The same tree in the Elements panel, after the page’s script ran: it added one more element, <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.css initiator (index):6: the page, line 6
      • /img/paper.png initiator style.css
      • https://cdn.mehditmimi.com/fonts/playfair-display-700.woff2 initiator style.css, on the second server
    • /app.js initiator (index):7: the page, line 7
      • /api/orders/42 initiator app.js:3: the script, line 3
    • /img/notebook.png initiator (index):12: the page, line 12
  • /favicon.ico initiator Other: the browser’s own, for the tab
The eight requests of one load, drawn by initiator, with the words of DevTools’ Initiator column. A path without a host is on mehditmimi.com; the font is on cdn.mehditmimi.com. Chromium 153, September 26, 2026, demo site served as mehditmimi.com.
Every request, as DevTools and the page itself saw it (one load, Chromium 153, September 26, 2026, demo site served as mehditmimi.com)
RequestServerDevTools’ Initiator columnThe page’s own view (initiatorType)Sent after the page’s request
/mehditmimi.comOtherthe navigation itself0 ms
/style.cssmehditmimi.com(index):6link8.3 ms
/app.jsmehditmimi.com(index):7script8.5 ms
/img/notebook.pngmehditmimi.com(index):12img8.6 ms
/img/paper.pngmehditmimi.comstyle.csscss14.3 ms
/fonts/playfair-display-700.woff2cdn.mehditmimi.comstyle.csscss14.5 ms
/api/orders/42mehditmimi.comapp.js:3fetch24.2 ms
/favicon.icomehditmimi.comOtherother34.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:

The console · the second server without 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.”

The Network table, with its Domain column shown, and Shift held over the style.css row: the page's row, mehditmimi.com, is green (its initiator), and the rows paper.png and playfair-display-700.woff2 are red (its dependencies). The Domain column reads mehditmimi.com on every row except the font's, cdn.mehditmimi.com.
Shift held over 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”):

The Initiator tab for the fetch of /api/orders/42. Request call stack: (anonymous) at app.js line 3. Request initiator chain: https://mehditmimi.com/, then https://mehditmimi.com/app.js, then https://mehditmimi.com/api/orders/42.
The data’s chain: the page, then 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.
The Initiator tab for the font playfair-display-700.woff2. Request initiator chain: https://mehditmimi.com/, then https://mehditmimi.com/style.css, then https://cdn.mehditmimi.com/fonts/playfair-display-700.woff2.
The font’s chain crosses servers: the page and 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”.

The Performance panel with the Network dependency tree insight open: Max critical path latency 30.52 ms, and the chain mehditmimi.com 16.00 ms, /style.css 14.93 ms, playfair-d…700.woff2 30.52 ms; beside it, the network track with the chained requests outlined in red.
The Network dependency tree insight, after “Record and reload” (a second load, recorded by DevTools). Chrome DevTools, Chromium 153, September 26, 2026, demo site served as mehditmimi.com.

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.

The servers’ logs · each request line, with its Referer header (and the font’s Origin) · Chromium 153 · demo site served as mehditmimi.com Sep 26, 2026
mehditmimi.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:

Requests of four public pages, one visit each, and of this episode’s demo page
PageRequestsLinks in the longest chain below the page
example.com10
mehditmimi.com, the real home page (this website)112
Wikipedia, the “Web browser” article473
MDN, “How browsers work”783
This episode’s demo page82

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:

style.css · the lines that name other files, and the rule that uses the font demo site served as mehditmimi.com
 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:

The demo page at 35 ms: the card with the small site name mehditmimi.com, the heading Order 42 in Georgia, the stand-in font, the notebook image and 1 notebook, with no status line yet.
35 ms: the heading in the stand-in font (Georgia).
The demo page at 63 ms: the heading Order 42 in Playfair Display, narrower than in Georgia, and the green line Status: shipped under 1 notebook.
63 ms: Playfair Display, and the script’s status line.

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:

The Styles pane for the h1. First the page's rule from style.css line 32: margin 0 0 18px, font 700 48px / 1.1 Playfair Display, Georgia, serif. Then the browser's own h1 rule, marked user agent stylesheet, where font-size 2em and font-weight bold are struck through. Then, inherited from body, the body rule from style.css line 12.
The Styles pane for the <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. A blank white frame.1 msBlank, and nothing new is shown until 1,043 ms.
  2. The styled demo page: the card with the small site name mehditmimi.com, the heading Order 42 in the stand-in font, the notebook image and 1 notebook, on a plain beige background without the paper texture.1,043 msThe first frame: styled, in the stand-in font.
  3. The styled demo page with its heading in Playfair Display, on the paper texture.2,060 msThe font has arrived.
  4. The finished demo page, with the green line Status: shipped under 1 notebook.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:

The demo page with only the browser's default styles: white background, the site name mehditmimi.com in small plain text, the heading Order 42 in large bold Times New Roman, the notebook image at its own size at the left, and the text 1 notebook.
What the wait avoids: the browser’s default styles only. A real capture with an empty stylesheet, taken at the page’s load event: the same 11 elements. Chromium 153, September 26, 2026, demo site served as mehditmimi.com.
The demo page as first painted: a white card on a plain beige background, with the site name MEHDITMIMI.COM in small capitals, the heading Order 42 in the stand-in serif font, the notebook image and 1 notebook.
What the browser painted first: the styled page, with the stand-in font, at 1,047 ms. Chromium 153 screencast, September 26, 2026, demo site served as mehditmimi.com.

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:

The Performance panel with the Render-blocking requests insight open. It says requests are blocking the page's initial render, which may delay LCP, and lists one request, /style.css, 3.87 ms; the network track shows style.css (mehditmimi.com) outlined in red, above app.js and notebook.png.
The Render-blocking requests insight lists one request: /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:

What each browser showed while the stylesheet was on its way. Held back 1 s (10 s in the second row); times in milliseconds from the start of the navigation.
The stylesheetChromium 153Firefox 155WebKit 26.6
Linked in the head, as on our pagewaits first paint at 1,044 ms, styledwaits 1,040 ms, styledwaits 1,022 ms, styled
Linked in the head, 10 s latewaits 10,044 ms, styledwaits 10,043 ms, styledwaits 10,017 ms, styled
Added to the head by a scriptflash unstyled at 21 ms, styled at 1,046 msflash unstyled at 66 ms, styled at 1,085 mswaits 1,020 ms
Linked in the body, after the headingflash the heading only, above the link, unstyled at 13 ms; the rest waitsflash the whole page unstyled at 59 ms, styled at 1,085 mswaits 1,018 ms
Linked in the head, and a script asks for a size (offsetHeight) at 300 mswaits 1,034 msflash unstyled at 349 ms, styled at 1,123 ms, with a warning in the consolewaits 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:

app.js · the page’s script, 10 lines demo site served as mehditmimi.com
 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:

The same page, the same one-second wait for app.js: by default, and with defer (Chromium 153 trace, September 26, 2026, demo site served as mehditmimi.com; times from the start of the navigation)
What happenedBy defaultWith defer
The parser readslines 1 to 7 at 10.94 ms, then stopsthe whole page at 12.05 ms, in one pass
app.js arrives, and runs1,027.95 ms, and 1,028.02 ms1,024.23 ms, and 1,024.31 ms
The parser reads on from line 7at 1,028.43 ms: it waited about one second at line 7already done
First paint1,049.38 ms31.64 ms
DOMContentLoaded1,037.63 ms1,024.69 ms, after the script
The status linenever shown: the script failedadded
The console · the page loaded with line 7 as a script without defer · demo site served as mehditmimi.com Chromium 153 · Sep 26, 2026
Uncaught (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.

Four ways to load a script (the quotations are the HTML Standard’s)
The tagDownloadRuns
<script src>when the parser reaches it, and parsing stopsat 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:

Each box, as layout placed it (Chromium 153, September 26, 2026, demo site served as mehditmimi.com)
ElementPosition (x, y)Size (width × height)
body, the card164, 40472 × 500.3
p.site, the site name200, 68400 × 19.5
h1200, 95.5400 × 52.8
img200, 166.3400 × 250
p, “1 notebook”200, 432.3400 × 30
p.status, added by the script200, 478.3400 × 30
The first painted frame of the unslowed load: the main thread’s steps after the stylesheet arrived (Chromium 153 trace, September 26, 2026, demo site served as mehditmimi.com)
Step, as DevTools names itTimeWhat it did here
Recalculate style0.53 msrecalculated the style of 6 elements
Layout6.84 mslaid out 10 of 10 objects
Pre-paint0.07 msprepared the paint
Paint0.14 msrecorded the drawing commands
Layerize and Commit0.04 + 0.06 mshanded 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:

Three test changes made by the capture script after the load (Chromium 153 trace, September 26, 2026, demo site served as mehditmimi.com)
The changeSteps Chromium ranLayout
The heading’s color becomes crimsonstyle, pre-paint, paint, layerize, commitskipped
The card’s (the body’s) max-width becomes 460pxstyle, layout, pre-paint, paint, layerize, commit3 of 12 objects
The image gets transform: rotate(-3deg)style, layout, pre-paint, paint, layerize, commit4 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-Type says 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 h1 rule, 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, with Access-Control-Allow-Origin; our second server sends *.
  • The site’s name: the film’s mehditmimi.com and cdn.mehditmimi.com are 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 link the 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, async nor type="module"; async and 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

  1. 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.
  2. 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.
  3. 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.
  4. Hold Shift and hover over a font file: its initiator turns green.
  5. Click a font file, then its Initiator tab: the request initiator chain, from this page to the font.

B. The Performance panel

  1. Open the Performance tab and click Record and reload (the round arrow). Keep Screenshots ticked.
  2. In the Insights list, open Render-blocking requests, then Network dependency tree.
  3. In the Main track, find Parse HTML, Evaluate script, Recalculate style, Layout and Paint.

C. Slow it down

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

  1. A script in the head that reads the page. Without defer, it runs before the body exists. Add defer, or wait for DOMContentLoaded. Tested on September 26, 2026: our app.js, loaded without defer, got null for document.body and 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.
  2. Thinking a stylesheet never blocks anything. A stylesheet in the head holds back the first paint, and the deferred scripts behind it. With style.css held back one second, the first paint came at 1,044 ms, and app.js, which had arrived at 15 ms, ran at 1,036 ms.
  3. Thinking defer makes a script download faster. It changes when the script runs, not how fast it arrives. In both of our loads, app.js arrived after the same one-second wait (1,024.23 and 1,027.95 ms); only the parser and the first paint changed.
  4. Making every script async. An async script runs “as soon as it is available”, so a script that needs another one may run first. defer keeps 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.”
  5. 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 OK in its log, “has been blocked by CORS policy” in Chromium’s console, and the heading left in Georgia.
  6. 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: @import in 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 terms and their French equivalents
EnglishFrançais
parseranalyseur (parseur)
DOM tree; node; elementarbre DOM ; nœud ; élément
stylesheet; style rulefeuille de style ; règle de style
developer toolsoutils de développement
initiator; request initiator chaininitiateur ; chaîne d’initiateurs d’une requête
origin; cross-originorigine ; 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-blockingbloquant pour le rendu
flash of unstyled content (FOUC)flash de contenu sans style (FOUC)
preload scanneranalyseur de préchargement (preload scanner)
to defer a scriptdifférer un script
layout; paint; compositingmise en page ; peinture ; composition
layercalque

Let’s recap

  1. The server sends text, and the browser builds the DOM from it.
  2. The page asks for files, and those files ask for more.
  3. The developer tools show the initiator chain of every request.
  4. The style rules become the CSSOM, matched to the DOM.
  5. The browser waits for the stylesheet before it paints.
  6. By default, a script stops the parser, unless it has defer.
  7. JavaScript can change the DOM at any time.
  8. After layout places the boxes, paint and composite make the pixels.
  9. And any change can repeat these steps.

Each point links to its section, if you want to read it again.

Before and after

Next episode

Before any of this, the browser had to find the server from its name. Episode 05 follows that question: 05 · How does your browser find a website from its name?

In the course, the page’s own language comes next: module 1, HTML Essentials.

Sources and credits

All episodes