Home / Articles

Think in Invalidation: A Practical Model of How Browsers Render Pages

Learn to reason about browser rendering by asking what each change invalidates, from layout thrashing and paint cost to compositing, hydration and streaming.

Publisher: REACTAPP.TOP11 min read2533 words

This article is published in English.

Sources: The Web Page Rendering Process In Browsers: A Deep Dive

Almost every frontend developer can recite the rendering pipeline: HTML becomes the DOM, CSS becomes the CSSOM, and then come layout, paint and composite. That sequence is a fine introduction, but it is nearly useless when a real page stutters, because it describes a single pass while browsers spend their lives doing partial, repeated passes. This guide replaces the memorized list with a working model built around one question, so you can explain layout thrashing, slow paints, cheap animations and hydration delays with the same reasoning and confirm your guesses in DevTools.

Rendering is continuous, not a one-time event

A page is never rendered once and left alone. Scripts mutate the DOM, class changes alter styles, images and web fonts arrive late, users hover and scroll, animations tick every frame, and frameworks re-render components after state updates. Each of these events forces the rendering engine to answer the same question:

What is no longer valid, and how much work has to be redone?

Keeping that question in mind is more valuable than knowing the stage names. It is the reason two applications that look identical can perform very differently on the same phone: one keeps triggering expensive recalculation, the other mostly touches work the browser can reuse.

Step one: the browser builds internal models

When HTML arrives, the parser turns it into the DOM. The DOM is not an image of the page. It is a tree of elements, attributes and text nodes, plus the parent and sibling relationships between them. Take a small fragment of markup:

<main>
  <h1>Browser Rendering</h1>
  <p>Understanding what happens after HTML arrives.</p>
</main>

The parser converts it into a tree the engine can query and update:

Document
└── main
    ├── h1
    │   └── "Browser Rendering"
    └── p
        └── "Understanding what happens after HTML arrives."

Stylesheets go through their own processing. The browser parses them, applies the cascade and inheritance, works out which rules match which elements, and computes the final value of every property for every element. At this point nothing has been drawn. The engine is accumulating state, and it needs both the structure and the computed styles before geometry can be determined. A dependency-oriented sketch looks like this:

HTML ──→ DOM ───────────┐
                        ├──→ Style + Layout ──→ Paint ──→ Composite
CSS  ──→ Styles ────────┘

Treat that picture as a map of dependencies rather than a description of the implementation. Real engines contain many more internal stages, caches, invalidation tracking structures, multiple threads and platform-specific shortcuts. What the diagram gets right is which outputs rely on which inputs. If you want a closer look at how style values are resolved before layout, our article on where CSS fits in the browser pipeline goes deeper.

Layout is a dependency calculation

With the DOM and computed styles available, the browser computes geometry. Layout has to settle questions such as:

  • What are the width and height of each box?
  • Where is each box placed?
  • Where does text wrap inside a container?
  • Where do an element's children end up?
  • How does an element's size push or pull its neighbors?

The last question is what makes layout expensive. Suppose a paragraph grows taller because its font size increased. Everything after it in the flow may need to shift down, and a container's height may change, which can affect its own siblings. A change that looks local can ripple across a large region of the document. That is why it helps to think of layout as resolving a web of dependencies, not as dropping boxes onto a canvas.

Engines try hard to limit recalculation to the affected subtree, but how much work actually happens depends on the kind of change and on how the elements relate to each other. For example, content inside a fixed-size container generally ripples less than content in an auto-sized one.

Invalidation matters more than the pipeline

Imagine a script runs card.style.width = "500px". The browser now knows that its previous geometry for that card, and possibly for things around it, may be stale. Before the next frame it must figure out which stages to rerun. In simplified form:

Something changes
      │
      ▼
What does it invalidate?
      │
      ├── Style
      ├── Layout
      ├── Paint
      └── Composite

The key observation is that changes do not all travel the full length of the pipeline:

  • A new width alters geometry, so layout reruns and the affected area is usually repainted afterwards.
  • A new background color changes appearance without moving anything, so paint is needed but layout typically is not.
  • A new transform value can often be applied with far less work, but only when the browser's layer decisions and the surrounding conditions allow the update to be handled at the compositing stage.

This is why blanket advice such as "avoid repaints" or "just use the GPU" breaks down in practice. The useful diagnostic question is always: which work did this particular change invalidate? Our article on what each CSS change costs the browser catalogs common properties by the stages they tend to trigger.

How JavaScript forces rendering work early

A common way to create expensive rendering by accident is to interleave layout writes and layout reads. The loop below sets a width on each card and immediately asks for that card's measured width:

for (const card of cards) {
  card.style.width = "500px";
  const width = card.getBoundingClientRect().width;
  console.log(width);
}

Nothing is wrong with getBoundingClientRect() itself. It is a normal, useful API. The cost comes from the order of operations. The style assignment leaves layout work pending, and the very next line asks for a value that can only be correct once that pending work is done. The browser has no choice but to run style and layout synchronously, right there, before returning the number. In a loop, you get the pattern known as layout thrashing: write, read, write, read, with a forced layout on every iteration.

Restructuring the code so that all reads happen first and all writes happen afterwards removes the forced synchronization:

const widths = cards.map(card =>
  card.getBoundingClientRect().width
);

for (const card of cards) {
  card.style.width = "500px";
}

Now the measurements are taken against a single, already valid layout, and the writes are left for the browser to process together when it next renders. Note that the two versions are not strictly equivalent: the second one records widths before the change, so use this reordering only when that is what you actually need. If a read genuinely depends on a previous write, the usual techniques are to batch writes and schedule reads for the next frame, for example with requestAnimationFrame.

The broader lesson is not "never measure layout". JavaScript and rendering share the same thread and the same data, so your code can create dependencies that compel the browser to do immediately what it would otherwise have scheduled and batched efficiently. Layout thrashing has other subtle triggers too, which we explore in live DOM collections and layout thrashing.

Paint is a separate cost from layout

Once geometry is known, the browser still has to work out what each region looks like: backgrounds, borders, text glyphs, images, shadows, gradients, clipping and other effects. A compact way to keep the two stages apart:

  • Layout decides where things are and how big they are.
  • Paint decides what those things look like.

Changing background-color usually does not require new geometry for the whole document, while changing a size or position may. Do not conclude that paint is free, though. Painting large areas, heavy effects such as big blurred shadows, frequently changing content and overly broad invalidation can all push paint time high enough to delay a frame.

Rasterization and compositing produce the final frame

Paint produces drawing instructions, not necessarily the final pixels. Those instructions are then rasterized into pixel data, commonly split into tiles and handled with the platform's graphics stack. Compositing then assembles the resulting surfaces, or layers, into the frame that appears on screen.

This is the stage where transform and opacity earn their reputation. When the conditions are right, animating these properties lets an element move, scale or fade without the browser recomputing document layout or repainting the element's contents; it simply recombines existing layers.

The popular shortcut "transform runs on the GPU, so it is always fast" hides real costs, however:

  • Every extra layer consumes memory, which matters on low-end mobile devices.
  • Content still has to be rasterized at some point, and rasterization can be expensive.
  • Whether an element receives its own layer depends on browser heuristics and the rendering context, so the same CSS may behave differently in different situations.

The durable rule is simpler than any property list: a change that invalidates less leaves the engine less to recompute.

The frame budget is shared

A 60Hz display refreshes about every 16.67 milliseconds, because 1000 / 60 is roughly 16.67. At 120Hz the interval drops to about 8.33 milliseconds.

That interval is not a slice of CPU time reserved for your code. Within it the browser has to fit script execution, style calculation, layout, paint, rasterization, compositing, input handling, network callbacks and its own housekeeping. A single long JavaScript task can therefore hold up rendering even when the rendering work itself would have been trivial.

This is also why metrics such as FCP (First Contentful Paint) and INP (Interaction to Next Paint) exist separately. Showing content quickly and responding quickly to input are related goals, but they fail for different reasons and need to be measured separately.

Where the UI is built shapes rendering cost

Before the browser renders anything, an architectural choice has already influenced how much work it will face: where is the initial UI produced?

Client-side rendering

With CSR, the server sends a minimal HTML shell and the application code, and the device does most of the work:

Server
   │
   ├── HTML shell
   └── JavaScript + data
            │
            ▼
         Browser
            │
            ├── Parse JS
            ├── Execute JS
            ├── Fetch data
            ├── Build UI
            └── Render

Server-side rendering

With SSR, data fetching, application logic and HTML generation happen on the server before the response is sent:

Request
   │
   ▼
Server
   │
   ├── Fetch data
   ├── Execute application logic
   └── Generate HTML
            │
            ▼
         Browser
            │
            ▼
      Parse + Render

SSR can get meaningful markup to the browser sooner, whereas CSR shifts computation and rendering onto the user's hardware. Neither is better in general. The right choice depends on how complex the app is, what data it needs, how well responses can be cached, what devices your users have, and how much JavaScript eventually has to run in the browser anyway.

Hydration: looking ready is not being ready

Many modern frameworks combine the two approaches through hydration. The server sends HTML that can be displayed immediately, and client-side JavaScript then boots the application and attaches the event handlers needed for interaction:

Server HTML
     │
     ▼
Browser renders content
     │
     ▼
JavaScript loads
     │
     ▼
Application initializes
     │
     ▼
Hydration
     │
     ▼
Interactive UI

The consequence is easy to overlook: a page can appear finished well before it can respond to a click. Hydration can involve downloading, parsing, compiling and executing a lot of JavaScript, initializing components and wiring up listeners, all on the main thread. A large app can therefore make content appear faster while creating a burst of main-thread work right after, which is exactly when users start tapping.

That tension explains the current direction of rendering strategies: shipping less client JavaScript, splitting bundles, and activating only the parts of a page that truly need interactivity instead of the whole tree at once.

Streaming changes when work starts

Classic SSR builds the full response before sending any of it. Streaming sends HTML in chunks as it becomes available:

Server
  │
  ├── HTML shell ──────────→ Browser
  ├── Content ─────────────→ Browser
  ├── More content ────────→ Browser
  └── Remaining response ──→ Browser

The browser can parse and render the early chunks while the server is still producing the rest. Streaming does not make rendering cheaper overall. What it changes is the moment useful work can begin, which can shorten the path from navigation to visible content. Progressive hydration applies the same idea on the client, spreading initialization over time instead of activating the entire application in one block.

Watch the pipeline instead of memorizing it

The fastest way to build intuition is to observe the browser directly. Open the Performance panel in Chrome DevTools, record an interaction, and inspect what runs when the UI changes. Then set up a deliberate forced layout by writing a width and reading geometry right away:

element.style.width = "600px";
element.getBoundingClientRect();

Record again with an update that changes only a transform:

element.style.transform = "translateX(100px)";

The point of the exercise is not to label one property good and the other bad. For each recording, work through the questions in order:

  • Was style recalculated?
  • Did layout run, and was it forced from script?
  • Was any area repainted?
  • Did rasterization occur?
  • Could the update be handled entirely by the compositor?

Once you read traces this way, the Performance panel stops being a last-resort debugging tool and becomes a way to see how the engine reacts to each line of your code.

Beyond the six-step diagram

The textbook pipeline is still worth knowing:

HTML → DOM → CSSOM → Layout → Paint → Composite

The real system is richer. The engine keeps several linked representations in sync at all times, from the element tree and computed styles through box geometry and drawing instructions to raster tiles and composited layers. A change rarely forces all of them to be rebuilt. It works out what is still valid, marks what has gone stale and reuses everything it can.

Key takeaways

  • Replace "which optimization should I apply?" with "what changed, what did it invalidate, and why did the browser have to redo that work?"
  • Layout is a dependency problem; local-looking changes can ripple through the document.
  • Interleaved reads and writes force synchronous layout; separate them or defer reads to the next frame.
  • Paint and compositing have their own costs; transform and opacity are cheap only when layers and conditions cooperate.
  • The frame budget is shared with scripts and input handling, so long tasks hurt even when rendering is cheap.
  • CSR, SSR, hydration and streaming mostly move work in place and time; judge them by how much main-thread work lands on the user's device and when.