From the raw HTML bytes to the pixels on your screen: the critical rendering path explained, DOM, CSSOM, render tree, layout, paint, composite, and why reflows quietly wreck performance.
You type a URL, hit Enter, and a fraction of a second later a fully styled page appears. It feels like one instant event. It isn't. Between the server sending back a blob of text and you seeing pixels, the browser runs a precise assembly line called the critical rendering path. Every stage depends on the one before it, and any stage can stall the whole pipeline.
Most performance problems, janky scrolling, a button that lags when you click it, a page that sits blank for a beat too long, are really just one of these stages doing more work than it should. Once you can name the stages, you can name the bug.
Who this is for
Frontend developers who can write HTML, CSS, and JavaScript but have never looked under the hood at how they become pixels. No prior browser-internals knowledge needed. If you have ever wondered why "just adding a class" can cause a stutter, this is for you.
One sentence, then a picture
Rendering is the process of turning your HTML and CSS into a tree the browser understands, working out where everything goes, and then drawing it, in that order, every time.
Think of it like assembling flat-pack furniture. The HTML is the instruction booklet that lists every part and how they nest together. The CSS is the parts list that says which panel is which colour, which screw is which size. You cannot start screwing things together until you have read both the instructions and checked the parts. Only then can you lay the pieces out on the floor (layout), paint or stain them (paint), and finally slot the finished sections into place to form the wardrobe (composite).
The instruction booklet (how parts nest)HTML parsed into the DOM tree
The parts list (which panel, which colour)CSS parsed into the CSSOM tree
Reading instructions + parts togetherDOM + CSSOM merged into the render tree
Laying pieces out on the floorLayout, computing position and size
Painting and staining each piecePaint, filling in pixels, colours, shadows
Slotting finished sections togetherComposite, stacking layers into the final frame
Building a page is building flat-pack furniture.
The critical rendering path
Here is the whole pipeline. Bytes come in on the left; pixels come out on the right. The browser never skips a step, and it processes them strictly left to right.
The critical rendering path: from source files to pixels on screen.
1
HTML → DOM
The browser reads the HTML bytes, decodes them into characters, tokenizes the tags, and builds the **Document Object Model**, a tree of nodes where every element, attribute, and text node is an object you can read and change from JavaScript.
2
CSS → CSSOM
In parallel, every stylesheet (external, internal, and inline) is parsed into the **CSS Object Model**, a tree of style rules. Like the DOM, it is a tree, because styles cascade and inherit down from parents to children.
3
DOM + CSSOM → Render Tree
The two trees are merged. The render tree contains only what will actually be drawn: nodes with `display: none` are dropped entirely, and each remaining node carries its computed styles. `<head>`, `<script>`, and hidden elements never make it in.
4
Layout (a.k.a. reflow)
The browser walks the render tree and calculates the exact geometry of every node, its width, height, and x/y position on the page. This is where percentages, flexbox, and grid get resolved into real pixel boxes.
5
Paint
With geometry known, the browser fills in the actual pixels: text glyphs, colours, borders, shadows, gradients, images. The output is a set of draw commands, often split across several layers.
6
Composite
Finally the painted layers are stacked in the right order, respecting z-index, transforms, and opacity, and handed to the GPU to assemble the single frame you see. Smooth animations live here.
What happens at each stage, and what slows it down
Each stage has its own cost profile. Knowing which stage your change touches tells you how expensive it will be. Here is the cheat sheet.
Stage
What happens
What makes it slow
DOM build
HTML parsed into a node tree
Huge or deeply nested markup; thousands of elements
CSSOM build
Stylesheets parsed into style rules
Large CSS files; complex, deeply nested selectors
Render tree
Visible DOM nodes get computed styles
Mostly proportional to DOM + CSSOM size
Layout
Geometry of every box computed
Touching layout-affecting properties; large DOM; reading layout mid-write
Paint
Pixels filled per layer
Large paint areas; expensive effects (shadows, blur, gradients)
Composite
Layers stacked on the GPU
Too many layers; very large layers eating GPU memory
The six stages, what they do, and what makes them slow.
What blocks rendering
Two resource types can freeze the pipeline: CSS and JavaScript. CSS is render-blocking by default, the browser will not build the render tree (and therefore will not paint) until it has the CSSOM, because painting with the wrong styles would cause an ugly flash. JavaScript is parser-blocking: a plain <script> stops HTML parsing dead while it downloads and executes, because the script might call document.write or read the DOM.
index.html
html
<!-- BLOCKS: parser stops here until app.js downloads AND runs -->
<script src="/app.js"></script>
<!-- BETTER: download in parallel, run after HTML is parsed -->
<script src="/app.js" defer></script>
<!-- For independent scripts (analytics): run whenever ready -->
<script src="/analytics.js" async></script>
<!-- CSS is render-blocking: the page won't paint until this loads -->
<link rel="stylesheet" href="/styles.css" />
`defer`, download the script in the background while parsing continues, then execute it in order, just before DOMContentLoaded. The right default for app code.
`async`, download in the background, then execute the moment it arrives, interrupting parsing. Good for independent third-party scripts that do not touch your DOM.
No attribute, fully blocking. Avoid in <head> unless the script genuinely must run before the page is parsed.
Why CSS in the <head> is still correct
It is tempting to think render-blocking CSS is bad, so move it down. Do not. You want CSS discovered early so the CSSOM is ready by the time the DOM is. Instead, keep the critical CSS small and load non-essential styles separately. Block early on a little, not late on a lot.
Reflow vs repaint vs composite (and layout thrashing)
After the first render, the page is not frozen, JavaScript and user interaction keep changing it. The cost of a change depends on how far back up the pipeline it forces the browser to go. This is the single most useful performance idea in this whole article.
Reflow (layout) is the most expensive. Change something geometric, width, height, top, font-size, adding a DOM node, and the browser must recompute geometry, then repaint, then composite. The whole tail of the pipeline reruns.
Repaint is cheaper. Change only appearance, color, background, box-shadow, visibility, and geometry is untouched, so the browser skips layout but still repaints and composites.
Composite-only is cheapest. Animate transform or opacity and the browser can skip both layout and paint, just re-stacking existing layers on the GPU. This is why smooth 60fps animations stick to transform and opacity.
Layout thrashing is the classic trap. The browser is smart enough to batch your style writes and reflow once, *unless* you read a layout value in between. Reading something like offsetHeight forces the browser to flush all pending changes and reflow *right now* so it can give you an accurate answer. Do that inside a loop and you force a reflow on every single iteration.
thrash.js
javascript
// ❌ Layout thrashing: read -> write -> read -> write...// Each offsetWidth read forces a synchronous reflow.for (const box of boxes) {
const w = box.offsetWidth; // READ (forces reflow)
box.style.width = w + 10 + "px"; // WRITE (invalidates layout)
}
// ✅ Batch: read everything first, then write everything.const widths = boxes.map((box) => box.offsetWidth); // all READS
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + "px"; // all WRITES
});
Common mistakes that cost hours
Render-blocking scripts in `<head>`. A plain <script src> before your content stalls parsing and delays first paint. Add defer, or move it to the end of <body>.
Layout thrashing in loops. Interleaving DOM reads (offsetTop, getBoundingClientRect) and writes forces a reflow per iteration. Batch all reads, then all writes.
Animating layout properties. Animating width, top, or margin reflows every frame and drops your frame rate. Animate transform and opacity instead.
A huge DOM. Tens of thousands of nodes make every reflow and style recalculation slow. Virtualize long lists and prune unnecessary wrapper elements.
One giant CSS file. All of it is render-blocking. Ship the critical styles inline or small, and defer the rest so first paint is not held hostage.
Takeaways
The whole article in six lines
The browser runs a fixed pipeline: HTML→DOM, CSS→CSSOM, render tree, layout, paint, composite.
CSS is render-blocking; plain scripts are parser-blocking. Use `defer` for app code, `async` for independent scripts.
Reflow (layout) is the most expensive change, repaint is cheaper, composite-only is cheapest.
Animate `transform` and `opacity` to stay on the cheap composite-only path.
Layout thrashing, reading a layout value between writes, forces a reflow per read. Batch reads, then writes.
Most jank is one stage doing too much work. Name the stage, fix the bug.
Where to go next
Now that you can see the pipeline, the next step is measuring it on real pages and understanding the layout model that feeds the layout stage.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.