How the browser renders a page
Parsing into the DOM and CSSOM, what blocks parsing and rendering, layout, paint and composite, and layout thrashing, each with a live demo.
Once the bytes of a page arrive (see Networking), the browser turns them into pixels in a fixed order: parse HTML and CSS into trees, work out the style of every element, lay out the boxes, paint them, and composite the layers on screen. Knowing the order explains why a script delays the first paint, why some animations stutter, and why some loops freeze the page. The loading runs and timings in the demos are measured in your browser.
Parsing: the DOM, the CSSOM and the render tree
The browser reads HTML as a stream of tokens and builds the DOM, a tree of nodes that scripts can read and change. CSS is parsed into the CSSOM. Combining the two gives the render tree: only what will actually be drawn, with its final styles.
- The HTML parser never fails: it adds missing
<html>,<head>,<body>and<tbody>, closes a<p>when another starts, and moves misplaced tags. What you wrote and what the DOM holds can differ. - A preload scanner reads ahead while the main parser is blocked, so the browser can start downloading stylesheets, scripts and images it finds further down.
display: noneremoves an element and its children from the render tree;visibility: hiddenkeeps the box (it still takes space) but doesn't paint it.::beforeand::afterboxes exist only in the render tree, anddisplay: contentsdrops an element's own box but keeps its children.- The browser's developer tools show the DOM (Elements panel), not your source: check there when markup doesn't behave as written.
Try it: edit HTML and CSS and compare the trees
Select an element in either tree to see the rules that match it.
Do: Write valid, explicitly nested HTML, and check the Elements panel when layout surprises you.
Avoid: Relying on the parser to fix markup, such as a
<div>inside a<p>: it moves nodes and breaks your CSS selectors.
Read more:MDN: How browsers work(opens in a new tab)web.dev: Render-tree construction(opens in a new tab)MDN: CSS Object Model(opens in a new tab)
What blocks parsing and rendering
Two different things can stop the browser. A stylesheet blocks rendering: nothing is painted until the CSS in <head> has arrived, so the page doesn't flash unstyled. A classic script blocks the parser: it may change the document, so parsing stops until it has downloaded and run.
- A classic
<script src>in<head>runs before the body exists and holds back everything after it. At the end of<body>the content above can already be painted. - Scripts also wait for stylesheets earlier in the document, because they may read styles; a slow stylesheet followed by an inline script blocks both.
deferdownloads during parsing and runs after it, in document order, just beforeDOMContentLoaded. Module scripts behave the same by default.asyncruns as soon as it arrives, in whatever order the downloads finish; it suits independent scripts such as analytics.DOMContentLoadedfires when the HTML is parsed and deferred scripts have run;loadwaits for everything else too (images, stylesheets, async scripts).
Try it: load a real page with slow CSS and scripts
Pick a setup and load it. The page, stylesheet and scripts are real downloads from this site, slowed on purpose.
Do: Put scripts in
<head>withdefer(ortype="module"), andasynconly for scripts that depend on nothing.Avoid: Classic scripts in
<head>, and inline scripts right after a large stylesheet.
Read more:MDN: The script element(opens in a new tab)web.dev: Adding interactivity with JavaScript(opens in a new tab)MDN: DOMContentLoaded(opens in a new tab)
Layout, paint and composite
After style, the browser runs layout (the size and position of every box), paint (drawing text, colors and shadows into layers) and composite (putting the layers together on screen). A change only reruns the stages it affects, so some properties are far cheaper to animate than others.
- Geometry changes (
width,left,top,margin, font size) rerun layout, then paint and composite, often for neighboring elements too. - Visual-only changes (
color,background-color,box-shadow) skip layout but repaint. transformandopacityskip both: the compositor moves or fades an already painted layer, off the main thread, so the animation stays smooth while JavaScript is busy.- At 60 Hz a frame has about 16 ms for all of this, and script shares the same main thread; a long task means dropped frames.
will-change: transformpromotes an element to its own layer ahead of time; every layer costs memory, so use it sparingly.
Try it: animate one box with different properties
- Style: runsWork out which rules apply and the final values
- Layout: runsCompute every box's size and position
- Paint: runsDraw text, colors, borders and shadows into layers
- Composite: runsCombine the layers on the GPU, moved or faded
Simplified: what engines do for each property on every frame of the animation.
Main-thread frames: press Play
This meter counts frames on the main thread, so with the busy main thread on it drops for every property. The second box is a transform animation drawn by the compositor: watch it keep moving while a left or width box freezes. A page can't measure compositor frames itself; to see them, open the developer tools' Rendering panel and turn on frame rendering stats and paint flashing.
Do: Animate
transformandopacity: move withtranslate, grow withscale.Avoid: Animating
left,top,widthorheight, or puttingwill-changeon everything.
Read more:web.dev: Rendering performance(opens in a new tab)web.dev: Stick to compositor-only properties(opens in a new tab)Chrome: RenderingNG architecture(opens in a new tab)
Layout thrashing
The browser normally batches style changes and lays out once, just before the next frame. Reading a layout value such as offsetWidth after a change forces it to lay out now, a forced synchronous layout. Doing that in a loop is layout thrashing.
- Reads that force layout include
offsetWidth/Height/Top,clientWidth,scrollTop,getBoundingClientRect()andgetComputedStyle()for layout values. - Read everything first, then write everything: the reads share one layout, the writes are laid out once before the next frame.
- In separate pieces of code, do reads early and schedule writes in
requestAnimationFrame; observers (ResizeObserver,IntersectionObserver) give sizes without forcing layout. - In the Performance panel, a purple "Layout" block with a "Forced reflow" warning points at the line that caused it.
Try it: resize many elements two ways
Interleaved: read, write, read, write…
for (const el of boxes) {
const w = el.offsetWidth; // read: forces layout
el.style.width = w + 1 + "px"; // write: layout is dirty again
}Not run yet
Batched: all reads, then all writes
const widths = boxes.map((el) => el.offsetWidth); // reads: one layout
boxes.forEach((el, i) => {
el.style.width = widths[i] + 1 + "px"; // writes only
});Not run yet
Do: Batch DOM reads before writes, and cache sizes you read more than once.
Avoid: Reading
offsetWidthorgetBoundingClientRect()inside a loop that also changes styles.
Read more:web.dev: Avoid layout thrashing(opens in a new tab)What forces layout (Paul Irish)(opens in a new tab)