What you'll learn: By the end of this lesson you will be able to trace how a browser turns HTML, CSS and JavaScript into pixels, say which resources block parsing and which block rendering, choose between defer, async and type="module", use preload, preconnect and fetchpriority correctly, tell layout, paint and compositor-only changes apart, avoid layout thrashing, and reorder a slow loading waterfall.
A builder is handed the blueprint of a house in instalments. She can start laying out rooms as pages arrive. But she will not paint a wall until she holds the complete colour chart, because a later page may change a colour she already chose. And if one instalment says "stop and read this note before you continue", she downs tools until she has read it.
A browser works the same way. The critical rendering path is the sequence of steps between receiving the first bytes of a page and showing pixels. Anything on that path delays the first pixels; anything off it can arrive later without the visitor noticing. Seeing which is which is most of front-end performance work.
The running example is an online store's product page: a stylesheet, a web font for the product name, a large hero image, and a JavaScript bundle that adds the cart and image gallery. A visitor on a phone opens it for the first time, so nothing is cached. Sibling lessons cover How a Browser Actually Works, The Event Loop and the Main Thread, HTTP Caching for the Front End, Rendering Strategies, and Loading Performance and Core Web Vitals. This one stays on the path itself.
The core idea
The browser does five jobs, and each later job needs the output of the earlier ones.
Parse HTML into the DOM. The DOM (Document Object Model) is a tree of nodes representing the document.
Parse CSS into the CSSOM. The CSSOM (CSS Object Model) is the equivalent tree of style rules.
Combine them into a render tree. Each visible node gets its computed styles. MDN and web.dev use this name; some engines call a similar structure a layout tree.
Layout. Work out the size and position of every box.
Paint and composite. Fill in pixels, possibly on several layers, then assemble the layers in the right order.
Figure 1. From bytes to a render tree and layout
How to read this: two inputs run in parallel and meet at the render tree. Everything to the right of that point needs both, which is why a slow stylesheet delays the first paint even when the HTML is ready.
The browser can show a partly received page, because HTML is parsed incrementally. But it does not paint before the CSS it knows about has been processed. That one asymmetry explains most of what follows.
Stage names here follow MDN and web.dev; real engines divide the work more finely (Chromium has more internal stages), but the dependencies between stages, which you design around, stay the same.
From bytes to the DOM
Bytes arrive from the network. The parser decodes them into characters, turns characters into tokens (start tag, end tag, attribute, text) and builds nodes from the tokens. The HTML Living Standard defines tokenisation and tree construction in detail, including recovery from broken markup, so every browser builds the same tree from the same bytes.
Two properties matter for design:
Parsing is incremental. The DOM grows while the response is still downloading, so a server that streams the start of the HTML early lets the browser find the stylesheet and images sooner.
Parsing can pause. At a classic <script> with no attributes, the parser stops, fetches the script if needed, runs it, and only then continues, because the script might call document.write() or read the DOM built so far.
From CSS to the CSSOM, and style calculation
The browser turns each stylesheet into rules. Unlike the DOM, the CSSOM is not used incrementally: a later rule can override an earlier one, so the browser cannot trust a half-read stylesheet. That is MDN's explanation of why CSS is render-blocking.
Style calculation then works out, for each element, which rules match and what the computed values are. A node plus its computed styles is what the render tree is made of.
What is, and is not, in the render tree
Content
In the render tree?
Why
A visible <div> or <p>
Yes
It generates a box
An element with display: none, and its descendants
No
It generates no box
An element with visibility: hidden
Yes
Invisible, but still occupies space (web.dev)
<head>, <script>, <meta>
No
Not visible content
Layout, paint and compositing
Layout computes the width, height and position of every box. The first run is layout; later recalculations are reflows (MDN). They happen when something changes geometry, for example a window resize or an image arriving with a size the page did not reserve.
Paint fills in pixels: text, colours, borders, shadows, images. On first load the whole visible page is painted; afterwards only changed areas are repainted.
The browser can split content into layers and composite (assemble) them later. MDN lists elements and properties that get a layer of their own, including <video>, <canvas> and elements with opacity, a 3D transform or will-change. Compositing matters because moving or fading an already painted layer does not require painting it again. Layers are not free: web.dev warns that each needs memory and management, and on devices with limited memory the cost can outweigh the benefit.
What blocks what
"Blocking" means two different things.
Parser-blocking: the parser stops, so nothing after this point is added to the DOM until it finishes.
Render-blocking: the browser will not paint until the resource is handled.
CSS blocks rendering, and scripts wait for it
A stylesheet that applies to the screen is render-blocking by default: the browser holds back painting until it has both the DOM and the CSSOM (web.dev, Render-blocking CSS). It does not stop HTML parsing or downloading. It does stop scripts: MDN explains that a script which is neither deferred nor async waits for the stylesheets already parsed, because scripts often read styled values.
A media attribute can make a stylesheet non-blocking for the current screen. A media="print" sheet does not block the first render, and one whose query does not match is still downloaded, at lower priority, without blocking (web.dev).
Scripts: parser-blocking by default
A plain external script fetches and runs immediately while the parser waits. MDN adds that scripts are not render-blocking by default: one without type="module", async or defer blocks parsing, not rendering. In practice the difference is small, since a script in the <head> stops the parser before it reaches the body, so there is nothing to render yet. The blocking="render" attribute exists for making a script explicitly render-blocking.
Script
Fetch
Runs
Order kept
Delays DOMContentLoaded
Delays load
Plain external
When reached (or earlier, see below)
Immediately, parser paused
Yes
Yes (parsing is delayed)
Yes
defer
In parallel with parsing
After parsing, before DOMContentLoaded
Yes
Yes
Yes
async
In parallel with parsing
As soon as available, possibly before parsing ends
No
No
Yes
type="module"
Script and imports, in parallel
After parsing, like defer
Yes
Yes
Yes
Module with async
Script and imports, in parallel
As soon as available
No
No
Yes
Facts behind the table, from the HTML Living Standard and MDN:
A defer script and a module script without async go into the same list of scripts that will execute when the document has finished parsing. That is why modules behave like defer and why defer has no effect on them.
async scripts go into a set of scripts that execute as soon as possible, with no order guarantee. They still set a "delaying the load event" flag, so load waits.
async and defer apply only to external scripts; on an inline classic script they do nothing. A defer and async pair on a classic script behaves as async.
Scripts that JavaScript creates and inserts are async by default (the standard's "force async" flag starts true; only the parser clears it).
Figure 2. Choosing how a script loads
flowchart TD
accTitle: Choosing how an external script loads
accDescr: A decision tree. ES modules use type module. For classic scripts, one that must run before the parser continues stays a plain script, one that needs the DOM or ordering uses defer, and an independent one uses async.
S["External script"] --> Q1{"Is it an ES module?"}
Q1 -->|yes| M["type=module runs after parsing, in order"]
Q1 -->|no| Q2{"Must it run before the parser continues?"}
Q2 -->|yes| C["Plain script keep it tiny"]
Q2 -->|no| Q3{"Needs the DOM or script order?"}
Q3 -->|yes| D["defer"]
Q3 -->|no| A["async"]
How to read this: answer each question from the top. Most application bundles end at defer or type="module"; async suits independent code such as analytics; a plain script is the exception.
The store's bundle adds the cart and the gallery. It needs the DOM and nothing in the first paint needs it, so it becomes defer (or a module).
The preload scanner: finding resources early
If the main parser were the only thing finding resources, a blocking script would also block discovery of everything below it. Browsers avoid this with a preload scanner: a lightweight scan of the raw HTML that runs ahead of the main parser and starts requests for what it finds. MDN and web.dev describe it; it is a browser optimisation rather than something the HTML standard requires, and engines differ in details.
The scanner reads markup. It finds <link rel="stylesheet">, <script src> and <img src> in the server's HTML. It cannot find what exists only after other work runs. web.dev lists the usual ways to defeat it:
scripts, stylesheets or images that JavaScript injects;
above-the-fold images lazy-loaded through a data-src that a script turns into src;
markup rendered on the client;
images referenced from CSS (background-image), which are requested once the CSS is processed;
inlining so much (large base64 data) that later resources are discovered late.
Figure 3. The parser pauses; the scanner keeps going
sequenceDiagram
accTitle: Parser and preload scanner on the product page
accDescr: The server sends HTML. The preload scanner requests the stylesheet, script and hero image while the main parser pauses at a plain script, which then waits for the stylesheet and runs.
participant S as Server
participant P as Main parser
participant N as Preload scanner
S-->>P: first chunk of HTML (head)
N->>S: scans ahead, requests app.css
N->>S: requests bundle.js and hero image
P->>P: reaches plain script, pauses
S-->>P: app.css arrives, CSSOM built
S-->>P: bundle.js arrives
P->>P: script runs (after CSS is ready)
P->>P: parsing resumes, body becomes the DOM
How to read this: time runs downwards. The hero image starts downloading while the parser is paused, which is the scanner's job. But the body is not in the DOM until the plain script has run, and the script itself waited for the CSS.
The rule: put what the first paint needs in the server's HTML as plain markup, where the scanner can see it. If the hero is an <img src> in the HTML it starts early. If the bundle builds it, the image starts only after the bundle has downloaded, parsed and run.
Resource hints: telling the browser what it cannot guess
Hints declare what you know the page will need. They affect order and priority; they do not make the network faster.
Hint
What it does
Watch out for
<link rel="preload" href="..." as="...">
Fetches the resource early and caches it. It does not run a script or apply a stylesheet.
as is required. Fonts need crossorigin even when same-origin. Unused preloads waste bandwidth.
<link rel="preconnect" href="https://...">
Starts DNS, TCP and (for HTTPS) TLS to another origin ahead of time.
Helps cross-origin requests only. Reserve it for the few most critical origins; use dns-prefetch for the rest.
fetchpriority="high" or "low"
A hint to raise or lower a resource's fetch priority relative to similar ones (auto is the default).
A hint, not a directive. It does not make a resource discoverable earlier.
Sources: MDN on rel="preload" and rel="preconnect", and web.dev's Fetch Priority article. MDN marks preload and preconnect as widely available (since January 2021 and January 2020). At the time of writing web.dev lists fetchpriority support from Chrome and Edge 102, Firefox 132 and Safari 17.2; check MDN's compatibility table before relying on it.
web.dev notes that Chromium starts images at low priority and raises them once layout shows they are in the viewport, so fetchpriority="high" on the largest visible image lets it start at high priority at once. Preload is not a substitute for discoverability: use a plain <img src> where you can, and preload only what the scanner cannot see, such as a hero referenced from CSS.
<linkrel="preload"href="/fonts/brand.woff2"as="font"type="font/woff2"crossorigin><linkrel="preconnect"href="https://images.example-cdn.test"><imgsrc="/img/hero.avif"width="1200"height="600"fetchpriority="high"alt="Trail running shoe, side view">
The .test domain is reserved for documentation. Serving fonts and images from a CDN is covered in CDN and object storage; caching their responses in caching basics.
Fonts and images and the first render
Fonts are discovered late. A web font is requested only after the render tree shows that some text needs it (web.dev), so the chain is HTML, CSS, render tree, then font request. Meanwhile font-display decides what the text does. In MDN's definitions, block gives a short block period (text drawn in an invisible fallback font) followed by an infinite swap period; swap has an extremely small block period and an infinite swap period; fallback and optional limit or remove the swap. The default, auto, is up to the browser. Preloading the font takes it off the chain so it downloads alongside the CSS. If the fallback and final fonts differ in size, text is laid out twice and the swap causes a reflow, so test it on a throttled connection.
Images do not block rendering, but they move things. An <img> does not hold back the first paint. But when the browser does not know its size it lays the page out without it and reflows when the image arrives; MDN notes that defined dimensions would have avoided the reflow. Set width and height (or CSS aspect-ratio) to reserve space. The largest image on screen is usually what visitors judge as "loaded": web.dev says never to lazy-load the Largest Contentful Paint image, and that it should be discoverable in the HTML source.
Which changes cost what: layout, paint or compositing
After the first render, every change re-enters the pipeline. How far it goes depends on what changed.
Figure 4. How far a change travels down the pipeline
flowchart LR
accTitle: Which pipeline stages a style change triggers
accDescr: A change that affects geometry triggers style, layout, paint and composite. A change that affects only appearance skips layout. A transform or opacity change on a promoted layer can go straight to composite.
A["Style change"] --> B{"Affects geometry? width, height, top, left"}
B -->|yes| L["Style, layout, paint, composite"]
B -->|no| C{"Appearance only? color, box-shadow, background-image"}
C -->|yes| P["Style, paint, composite"]
C -->|no| T["transform or opacity on its own layer: composite only"]
How to read this: cost falls from left to right. Layout is the most expensive path because it can affect other boxes, and it ends in paint and composite too.
The mapping comes from web.dev's rendering-performance guide: geometric properties such as width, height, left and top need layout; paint-only properties such as color, background-image and box-shadow skip it; a change needing neither can go straight to compositing, which web.dev recommends for animation and scrolling. web.dev's companion article on compositor-only properties names two, transform and opacity, on an element with its own compositor layer. Browsers evolve, so treat the two as a safe minimum rather than a full catalogue, and confirm in the DevTools Performance panel.
To move a banner across the screen, animate transform: translateX() rather than left; to fade it, animate opacity. MDN says style, reflow and paint on the main thread must fit in 16.67 ms (one frame at 60 frames per second) for smooth animation.
Layout thrashing
The browser does not run layout on every style change. It batches changes and does the work later in the frame. But if you read a layout-dependent value such as offsetWidth or offsetHeight (getBoundingClientRect() is another common one) after a write, the browser must run style and layout immediately to answer correctly. That is a forced synchronous layout; doing it repeatedly in a loop is layout thrashing.
This loop thrashes. The gallery sizes every thumbnail from its container's width:
const container = document.querySelector(".gallery");
const thumbs = document.querySelectorAll(".gallery img");
// Slow: after the first iteration, each read of offsetWidth follows a writefunctionsizeThumbsSlow() {
for (const img of thumbs) {
img.style.width = `${container.offsetWidth / 4}px`; // read, then write
}
}
Each iteration's write dirties layout, so the next read forces a recalculation. The fix is to read once, then write:
// Fast: one read, then writes onlyfunctionsizeThumbs() {
const width = container.offsetWidth / 4; // read before any writefor (const img of thumbs) {
img.style.width = `${width}px`; // writes only
}
}
Both versions leave the thumbnails at a quarter of the container's width; the second runs one layout instead of one per thumbnail. The rule, in web.dev's words, is to batch style reads and do them first. DevTools flags forced reflows in the Performance panel.
The order of lifecycle events
document.readyState and two events report progress. They are triggered by different things.
Figure 5. Document lifecycle: what triggers each step
stateDiagram-v2
accTitle: Document lifecycle from loading to load
accDescr: The document starts in loading, becomes interactive when the parser finishes, fires DOMContentLoaded after deferred and module scripts have run, and becomes complete, firing load, when stylesheets, images, async scripts and iframes have finished.
state "readyState: loading" as Loading
state "readyState: interactive" as Interactive
state "DOMContentLoaded fired" as DCL
state "readyState: complete" as Complete
[*] --> Loading: navigation starts
Loading --> Interactive: parser reaches end of the HTML
Interactive --> DCL: defer and module scripts have run
DCL --> Complete: stylesheets, images, async scripts, iframes done
Complete --> [*]: load event fires
How to read this: each arrow is labelled with its trigger. DOMContentLoaded depends on parsing and deferred scripts; load depends on every dependent resource.
DOMContentLoaded fires when the HTML is completely parsed and all deferred and module scripts have downloaded and run (MDN). A plain script delays it because it delays parsing. It does not wait for images, subframes or async scripts, nor for stylesheets themselves. But a non-deferred, non-async script waits for earlier stylesheets, so a stylesheet followed by a plain script can delay it indirectly.
load fires on window when the whole page has loaded: stylesheets, scripts (including async, deferred and module), iframes and images, except those loaded lazily (MDN).
Use DOMContentLoaded for work that needs the DOM and load only when you must wait for images or other late resources. If your code might run after the event has fired (for example in an async script), check document.readyState first:
functionstart() {
console.log("DOM is ready");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start(); // the parser has already finished
}
While the document is still parsing this logs DOM is ready when the event fires; if the script runs later, the state is interactive or complete and it logs immediately.
In practice: reordering the product page's load
NOTE
A realistic scenario, not a case history. This is a composite example built from common patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A small store's product page loads slowly on mid-range phones over mobile data. The visitor sees a blank screen, then the product name, and the hero image arrives last. The four front-end developers suspect the JavaScript bundle (illustratively, 400 KB). The page is server-rendered HTML plus a stylesheet, one web font for the product name, a hero image, and a bundle that, as written, renders the hero gallery on the client.
Starting architecture
Figure 6. The request chain before the change
How to read this: each arrow means "only discovered once the previous item has been handled". The font and the hero image sit at the end of two chains, so their downloads start late.
The script is a plain script in the <head>, so it blocks the parser and waits for the CSS. The hero is not in the HTML; the bundle creates it, so the scanner cannot see it. The font is requested only once the render tree needs it. An illustrative waterfall, in milliseconds from navigation start, simplified so that all subresource requests begin when the HTML arrives and parallel downloads do not share bandwidth (in reality they do):
Request
Started
Finished
Why it started then
HTML
0
400
Navigation
app.css
400
700
Found in the head
bundle.js
400
1000
Found in the head
Script runs
1000
1200
Waits for the download (the CSS is ready at 700)
Hero image
1200
1900
Created by the script
Brand font
1250
1650
Needed once the body is in the render tree
On these illustrative numbers text stays invisible until 1650 (block period) and the hero appears at 1900.
Options considered
Option
What it fixes
What it costs
Risk
Shrink the bundle
Shorter script download
Weeks of code splitting
The hero still starts after the script
Server-render the hero <img>, keep the bundle as is
Hero discoverable from the HTML
Template change
Low
Hero in HTML plus defer, font preload, fetchpriority="high"
Removes both chains
Small markup changes
Low; a wrong preload wastes a request
Inline the critical CSS
Faster first render
A build step
Medium
The decision
Field
Decision
Context
Three resources the first paint needs sit at the end of dependency chains.
Decision
Put the hero <img> in the server's HTML with width, height and fetchpriority="high". Mark the bundle defer. Preload the font with crossorigin. Keep the stylesheet render-blocking.
Consequences, good
All four requests start when the HTML arrives; first paint waits only for the CSS, and the text appears once the font has arrived.
Consequences, bad
The cart is inactive until the bundle runs after parsing; the template now carries the hero markup.
Revisit when
The CSS is the longest remaining step; then consider inlining critical rules.
<head><metacharset="utf-8"><title>Trail Running Shoe</title><linkrel="preload"href="/fonts/brand.woff2"as="font"type="font/woff2"crossorigin><linkrel="stylesheet"href="/css/app.css"><scriptsrc="/js/bundle.js"defer></script></head><body><imgsrc="/img/hero.avif"width="1200"height="600"fetchpriority="high"alt="Trail running shoe, side view"><h1>Trail Running Shoe</h1></body>
Figure 7. The request chain after the change
How to read this: one document fans out to four parallel requests, because each is visible in the markup. First paint depends on the stylesheet, and the text becomes visible when the font arrives; the script only has to be ready after parsing.
On the same illustrative numbers the new waterfall is: CSS 400 to 700, font 400 to 800 (the same 400 ms download), hero 400 to 1100 (the same 700 ms download), bundle 400 to 1000, running 1000 to 1200 after parsing. The first paint can happen at about 700, when the CSS is ready, with the text visible at about 800 when the font arrives, and the hero at 1100, instead of 1650 and 1900. These figures are the example's assumptions, not a measurement; the lesson is in their shape: removing chains matters more than shrinking any single file.
Rollout and verification
Ship the markup change behind a flag for a slice of traffic. Compare the same page, device class and throttled network profile in DevTools, and read the Network panel for the order in which requests start, not only the totals. In the field, compare Largest Contentful Paint (LCP) before and after, as covered in the Loading Performance and Core Web Vitals lesson. web.dev's LCP guide splits LCP into four parts (time to first byte, resource load delay, resource load duration and element render delay) and wants the load delay to be a small share; this change targets that part.
The 2 a.m. failure
A release moves the font to a new versioned path. The <link rel="preload"> is updated, but crossorigin is dropped in the edit. Nothing visibly breaks. But MDN says a font preload needs crossorigin to match the font's eventual request, because fonts are fetched in anonymous CORS mode. web.dev warns that a font preloaded without crossorigin is fetched twice, so the text appears later and LCP can regress.
Detection: an alert on LCP, and a console warning in Chromium-based DevTools that a preloaded resource was not used. First checks: the Network panel (is the font listed twice?), the <link> tag (as, type, crossorigin), and the Link response header if a CDN injects hints. Mitigation: restore the attribute and redeploy. Follow-up: a build test asserting that every as="font" preload has crossorigin.
Architect's notes
Decide what is critical. Write down what the first screen needs (HTML, one CSS file, perhaps one font and one image). Everything else is a candidate for defer, async or lazy loading.
Discoverability before priority. A resource the scanner sees in the HTML beats a clever hint for one it cannot.
Default scripts to defer or modules. A plain script in the head should need a written reason.
Hints are claims you must maintain. Preloads and preconnects go stale when a file is renamed or an origin changes; check them in the build.
Measure the same way before and after. Field data for outcomes, lab traces to explain them.
Defer micro-optimisation. Inlining CSS or tuning layers pays off after the chains are gone.
Common mistakes
Treating async as "faster". Async scripts run in whatever order they arrive and still delay load. Code that depends on other code needs defer.
A plain script above the content. It pauses the parser and waits for the CSS.
Hiding the hero behind JavaScript or loading="lazy". The scanner cannot see it, or the browser delays it on purpose.
Preloading without as or crossorigin, or preloading everything. Hints compete for bandwidth.
Unsized images and swapping fonts. Both cause reflows after first paint.
Interleaving layout reads and style writes in a loop.
Animating left, top or width, and promoting every element to a layer.
Key takeaways
The path is HTML to DOM, CSS to CSSOM, both to the render tree, then layout, paint and composite. Painting needs both inputs.
CSS is render-blocking, and it delays scripts that follow it. Plain scripts block the parser.
defer and module scripts run after parsing, in order, before DOMContentLoaded; async runs as soon as available, in no guaranteed order.async does not delay DOMContentLoaded but does delay load.
The preload scanner finds resources in server HTML. Anything injected by JavaScript is invisible to it.
preload needs as (and crossorigin for fonts); preconnect helps cross-origin requests; fetchpriority is only a hint.
Fonts start after the render tree; images need reserved space. Preload the font and size the image.
Geometry changes cost layout, appearance changes cost paint, and transform and opacity can be composite-only. Confirm in DevTools.
Read layout values before writing styles. Interleaving them in a loop is layout thrashing.
Practice exercises
Exercise 1: Starter (30 min). On a page you build, open DevTools, Network tab, throttled to a slow connection. Note the order in which the CSS, script, font and hero image start, and whether the preload scanner could have found each in the HTML.
Exercise 2: Intermediate (1 hour). Make a page with a plain script in the head, then change it to defer, async and type="module" in turn. In each version log document.readyState from the script and from a DOMContentLoaded handler, and explain the order using this lesson's table.
Exercise 3: Intermediate (1 hour). Build a page of 200 boxes and write the thrashing and batched versions of a loop that sizes them from a container's offsetWidth. Compare both in the Performance panel and find the forced reflow warning.
Exercise 4: Advanced (half day). Pick a real page, draw its dependency chain as in Figure 6, then flatten it one change at a time, recording the effect with the same throttling profile.
Lesson 4 of 8 · Frontend System Design Foundations: How the Web Runs
Follow HTML bytes to pixels through the DOM, CSSOM, layout, paint and compositing, learn what blocks rendering, and reorder a product page's load.
In the rest of this lesson
The preload scanner: finding resources early
Resource hints: telling the browser what it cannot guess
Fonts and images and the first render
Which changes cost what: layout, paint or compositing
The order of lifecycle events
In practice: reordering the product page's load
Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.