Frontend Handbook
Written

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.

01

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: none removes an element and its children from the render tree; visibility: hidden keeps the box (it still takes space) but doesn't paint it.
  • ::before and ::after boxes exist only in the render tree, and display: contents drops 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

How the browser drew it (scripts in the sample don't run)

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)

02

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.
  • defer downloads during parsing and runs after it, in document order, just before DOMContentLoaded. Module scripts behave the same by default.
  • async runs as soon as it arrives, in whatever order the downloads finish; it suits independent scripts such as analytics.
  • DOMContentLoaded fires when the HTML is parsed and deferred scripts have run; load waits 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> with defer (or type="module"), and async only 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)

03

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.
  • transform and opacity skip 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: transform promotes 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

Animate the box with
  1. Style: runsWork out which rules apply and the final values
  2. Layout: runsCompute every box's size and position
  3. Paint: runsDraw text, colors, borders and shadows into layers
  4. Composite: runsCombine the layers on the GPU, moved or faded

Simplified: what engines do for each property on every frame of the animation.

left
transform

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 transform and opacity: move with translate, grow with scale.

    Avoid: Animating left, top, width or height, or putting will-change on 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)

04

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() and getComputedStyle() 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

Elements to resize

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 offsetWidth or getBoundingClientRect() 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)