Browser Rendering in Depth: DOM, CSSOM, Layout, Paint, Compositing, and Hydration

14 min read

Part 1 of this series ended with "the browser renders the page" as the final step. That sentence is where many frontend investigations begin. A page can have a fast server response and still feel slow because CSS delays rendering, JavaScript blocks the main thread, layout changes repeatedly, or hydration has not finished.

This is Part 6 of the How the Web Works series. It covers what a browser does with HTML, CSS, and JavaScript after bytes start arriving.


Quick Answer

Rendering is not one step. It is a set of stages, and browsers try to avoid re-running the expensive parts more than necessary:

HTML  →  DOM
CSS   →  CSSOM
              ↘
               Render Tree  →  Layout  →  Paint  →  Composite  →  Pixels on screen
              ↗
JS  →  can modify DOM/CSSOM, re-triggering any of the steps above

Every animation frame, scroll event, or DOM mutation can re-run some subset of these stages. Which subset runs often determines whether a page feels smooth or janky.


Where This Layer Sits in the Bigger Picture

By this point in the series, DNS (Part 3) has resolved the domain, TLS (Part 4) has secured the connection, HTTP (Part 2) has delivered the response, and the server (Part 5) has already done its work. Everything in this part happens entirely on the client, inside the browser, using the HTML/CSS/JS bytes that arrived.


Parsing HTML into the DOM

The browser parses HTML top to bottom, building the DOM (Document Object Model). The DOM is a tree of nodes representing elements, text nodes, and comments in the document.

<html>
  <body>
    <h1>Title</h1>
    <p>Text</p>
  </body>
</html>

        html
         |
        body
       /    \
      h1      p
      |       |
    "Title" "Text"

This parsing is incremental and streaming. The browser does not wait for the entire HTML document before starting to build the DOM. This is why streaming server-side rendering can show useful content before the full page has finished downloading.

A parsing detail that trips people up: the parser can pause when it hits a <script> tag without async/defer, because a synchronous script could call document.write() and change what comes after it. The browser has to stop and run the script before continuing to parse. This is the historical reason "put scripts at the bottom of the body" became a rule of thumb. async and defer are usually better tools now.


Parsing CSS into the CSSOM

In parallel with DOM construction, the browser parses CSS from external stylesheets, <style> blocks, and inline styles into the CSSOM (CSS Object Model). The CSSOM represents style rules and cascade/specificity resolution.

body { font-size: 16px; }
p    { color: #333; }

           CSSOM
      body { font-size: 16px }
        └── p { color: #333, font-size: 16px (inherited) }

Unlike HTML parsing, CSS is often render-blocking. For stylesheets that apply to the current page, the browser generally waits before painting content that depends on those styles. This avoids painting with incomplete style information and then redrawing. Large render-blocking stylesheets can delay the first visible content even when HTML arrives quickly.


Building the Render Tree

Once both the DOM and CSSOM exist, the browser combines them into a render tree. This tree contains nodes that participate in rendering, each with computed styles.

DOM node with display:none  →  excluded entirely from the render tree
DOM node with visibility:hidden  →  included (still takes up space), just not painted

This distinction matters: display: none removes an element from layout, while visibility: hidden keeps its space reserved but invisible.


Layout (Reflow): Computing Geometry

Layout (also called reflow) is the step where the browser calculates the exact size and position of every element in the render tree, in pixels, based on the viewport, box model, and CSS rules.

Render tree node: <div style="width: 50%">
      ↓ layout
Computed geometry: x=0, y=120, width=640px, height=200px

Layout is one of the more expensive rendering steps, and it is not isolated per element. Changing one element's size or position can force recalculating the geometry of other elements around it. This is why layout is also called "reflow."

// Triggers a forced synchronous layout: a classic performance bug
element.style.width = '100px'
console.log(element.offsetHeight)   // forces layout to run *right now*, synchronously
element.style.height = '200px'
console.log(element.offsetHeight)   // forces layout AGAIN, immediately

Reading a layout-dependent property (offsetHeight, getBoundingClientRect(), etc.) right after writing a style can force the browser to run layout synchronously instead of batching work efficiently. Doing this in a loop is a common performance trap called layout thrashing.


Paint: Turning Boxes into Pixels

Paint takes the laid-out boxes and fills in pixels: text, colors, borders, shadows, and images. Browsers often represent this as paint records, which are lists of drawing instructions.

Layout gave us:   a box at (0, 120), 640×200px
Paint fills it:   background color, border, text glyphs, box-shadow

Paint does not necessarily repaint the entire page. The browser tries to limit repainting to the region that changed. Some CSS changes, such as background color changes, require more painting than transform or opacity, which can often skip paint.


Compositing: Layers and the GPU

Modern browsers split certain elements onto their own compositor layers, which can be moved, faded, or transformed directly by the GPU without re-running layout or paint at all.

Normal element:      part of the main page layer, moving it may trigger layout + paint
Composited element:  its own layer, transforming it may only need the compositor

This is why animating transform and opacity is usually cheaper than animating top/left/width/height. The former can often be handled by the compositor on the GPU, while the latter may force layout and paint on every frame.

/* Expensive: triggers layout + paint on every frame */
.moving-slow { left: 100px; transition: left 0.3s; }

/* Cheap: compositor-only, skips layout and paint */
.moving-fast { transform: translateX(100px); transition: transform 0.3s; }

will-change: transform is a hint that can promote an element to its own layer ahead of time. It is useful for known upcoming animations, but overuse creates extra layers that consume GPU memory without improving performance.


The Critical Rendering Path

The critical rendering path is the minimum sequence of steps needed to render the first meaningful frame of a page: parsing, style, layout, and paint. Optimizing it means reducing what must happen before that first paint:

Render-blocking CSS      →  inline critical CSS, defer the rest
Render-blocking JS        →  use async/defer, avoid document.write
Large DOM/CSSOM trees     →  simpler markup and stylesheets parse and lay out faster
Unnecessary web fonts     →  font-display: swap avoids blocking text rendering entirely

font-display: swap deserves a specific mention. Without it, a custom web font can leave text invisible for a while during font download, sometimes called the "flash of invisible text" or FOIT. swap shows a fallback font immediately and swaps it once the custom font loads.


Blocking Resources: Scripts and Stylesheets

Loading strategyBehavior
<script src="..."> (default)Blocks HTML parsing until downloaded and executed
<script async src="...">Downloads in parallel, executes as soon as it is ready; parsing pauses only for execution, and scripts may run out of order relative to each other
<script defer src="...">Downloads in parallel, executes only after parsing completes, in document order
<link rel="stylesheet">Render-blocking by default
<link rel="preload">Fetches early without blocking, for a resource you know you'll need soon

defer is usually the right default for scripts that need the DOM to exist and need to run in a predictable order relative to each other, such as application bootstrap code. async fits independent scripts like analytics, where execution order relative to other scripts does not matter.


JavaScript Execution and the Event Loop

Once scripts run, they execute on the browser's single main thread, the same thread responsible for layout, paint, and responding to user input. A long-running synchronous JavaScript task blocks all of it, which is why heavy computation can freeze scrolling and make clicks feel unresponsive.

Main thread:  [parse] [style] [layout] [paint] [JS task] [JS task] [user click waits here...]

This single-threaded model is why breaking up long tasks with setTimeout, requestIdleCallback, or chunks of work matters for responsiveness. The goal is not always to do less work; it is to give the main thread chances to handle layout, paint, and input between chunks.


Hydration: From Static HTML to Interactive App

Frameworks that render HTML on the server, or at build time as with static generation, send a formed page to the browser. That HTML alone has no framework event listeners or client-side state attached yet. Hydration is the process of client-side JavaScript attaching behavior and framework state to the existing DOM nodes.

Server/build time:  render HTML  →  send to browser  →  visible immediately, but not interactive yet
Client, after JS loads:  hydration  →  event listeners attached  →  now interactive

The gap between "visible" and "interactive" affects responsiveness. A page can look ready while a click does nothing yet because hydration has not finished attaching listeners. This can hurt INP when user input is delayed.

Hydration mismatches happen when server-rendered HTML does not match what the client renders from the same data. Common causes include Date.now(), Math.random(), and browser-only APIs during server rendering. Mismatches produce console warnings and can force the framework to rebuild DOM instead of reusing it.


Core Web Vitals: LCP, INP, CLS

Google's Core Web Vitals are three specific, measurable metrics that map directly onto the pipeline covered above:

MetricMeasuresRendering-pipeline cause
LCP (Largest Contentful Paint)How long until the largest visible element rendersSlow server response, render-blocking resources, slow image loading
INP (Interaction to Next Paint)How responsive the page is to user inputLong JavaScript tasks blocking the main thread, delayed hydration
CLS (Cumulative Layout Shift)How much visible content unexpectedly shifts aroundImages/ads/fonts loading without reserved space, triggering layout after initial paint
Fix for CLS:  always specify width/height (or aspect-ratio) on images and embeds
              so their space is reserved in layout *before* they finish loading

CLS is often mechanical: something rendered into space that had not been reserved yet. Reserving that space upfront usually eliminates the shift.


Common Rendering Performance Mistakes

MistakeWhy it hurts
Animating top/left/width instead of transformForces layout + paint on every frame instead of compositor-only work
Reading layout properties right after writing styles, in a loopCauses layout thrashing: repeated forced synchronous layout recalculation
Render-blocking web fonts without font-display: swapText can be invisible for seconds while the font downloads
Large synchronous JS bundles with no code splittingBlocks the main thread longer, delaying both paint and interactivity
Images/ads without reserved dimensionsCauses layout shift (poor CLS) once they finish loading
Over-using will-change on many elementsCreates excessive GPU-backed layers, consuming memory without a real benefit

Practical Debugging Checklist

1. Use the Performance panel to see the actual pipeline stages

Chrome DevTools → Performance → record a scroll/interaction → look for Layout, Paint, and Composite blocks specifically, and how often they repeat.

2. Check for forced synchronous layout ("layout thrashing")

DevTools will explicitly flag "Forced reflow" warnings in the Performance panel timeline when this happens.

3. Check Core Web Vitals directly

Chrome DevTools → Lighthouse, or
PageSpeed Insights, or
the web-vitals JavaScript library for real-user field data

4. Check for hydration mismatches

Look for hydration warnings in the browser console. They often name the mismatched element, which is usually the fastest way to locate the cause.

5. Profile long JavaScript tasks

DevTools Performance panel flags any task over 50ms as a "long task." These tasks can block input responsiveness and hurt INP.


Performance Questions Worth Measuring

The short explanation:

The browser parses HTML and CSS, builds the DOM, and renders the page.

When a page feels slow, measure:

  • Why layout and paint are separate, expensive steps, and why compositor-only properties (transform, opacity) skip both
  • What causes layout thrashing and how to avoid it
  • The distinction between a page being visible versus interactive, and how hydration bridges that gap
  • How each Core Web Vital maps to a specific, fixable rendering-pipeline cause
  • Why a single long JavaScript task can make a rendered page feel unresponsive

FAQ

Why is animating transform faster than animating left/top?

Because transform and opacity can often be handled by the compositor on the GPU, skipping layout and paint. Changing left or top can force the browser to recompute layout and repaint on every frame.

What's the actual difference between display: none and visibility: hidden?

display: none removes the element from the render tree, so it takes up no layout space. visibility: hidden keeps the element in layout, reserving its space, but does not paint it.

Why can a page look fully loaded but not respond to clicks?

Because visible content and interactivity are produced by different stages. HTML can be rendered and visible before client-side JavaScript has finished hydrating and attaching event listeners. This can contribute to poor responsiveness and worse INP.

What causes a hydration mismatch?

The server-rendered HTML does not match what the client produces from the same data. Common causes include Date.now(), Math.random(), and code that behaves differently when browser-only APIs are available.

Why does an unstyled "flash" sometimes appear before the real page shows up?

Usually it comes from font loading or CSS arriving after some content was already painted. Browsers often block paint on applicable stylesheets to avoid this, so a flash usually points to a stylesheet or font-loading gap.

What causes layout shift (poor CLS)?

An element renders into space that was not reserved ahead of time. Common causes are images, ads, or embeds without explicit width/height or aspect-ratio, so the browser does not know how much space to reserve before the content finishes loading.

Is more JavaScript always worse for rendering performance?

Not inherently, but a large, unsplit JavaScript bundle blocks the single main thread for longer during parsing and execution, delaying both initial paint and, more importantly, how quickly the page becomes responsive to interaction (affecting INP directly).


Glossary

TermSimple meaning
DOMTree representation of HTML elements the browser builds
CSSOMTree representation of all computed CSS style rules
Render treeNodes and styles selected for rendering from the DOM and CSSOM
Layout / reflowCalculating exact size and position of every element
PaintFilling in pixels, including color, text, and borders, for laid-out boxes
CompositingCombining layers, often GPU-accelerated, into the final frame
HydrationAttaching interactivity to server-rendered static HTML
Layout thrashingRepeatedly forcing synchronous layout recalculation in a loop
LCP / INP / CLSCore Web Vitals measuring load speed, responsiveness, and visual stability
Critical rendering pathThe minimum steps required to produce the first visible frame

Final Mental Model

HTML arrives  →  parsed into the DOM (incrementally, streaming)
CSS arrives   →  parsed into the CSSOM (blocks paint until complete)
      ↓
DOM + CSSOM combined into the render tree (visible nodes only)
      ↓
Layout: exact geometry computed for every element
      ↓
Paint: pixels filled in for each laid-out box
      ↓
Compositing: layers combined, GPU-accelerated where possible
      ↓
JavaScript hydrates the page, attaching real interactivity
      ↓
Core Web Vitals (LCP, INP, CLS) measure how well each of these stages performed

With this model, "the page feels janky" becomes a specific question: is this layout, paint, compositing, or JavaScript blocking the main thread? Each points to a different fix.


About the author

Suriyaprakash Somu is a full-stack developer from Erode, Tamil Nadu, building production-ready business applications with React, Node.js, Fastify, PostgreSQL and MySQL. He focuses on Access Control, schema-based forms, and reliable backend workflows.