Difficulty: Advanced
The Critical Rendering Path (CRP) is the sequence of steps the browser performs to convert HTML, CSS, and JavaScript into pixels on the screen. Understanding this pipeline is essential for optimizing page load performance and is one of the most frequently asked topics in frontend interviews. The CRP consists of five main steps: DOM construction, CSSOM construction, render tree assembly, layout (reflow), and paint (repaint), followed by compositing.
DOM construction begins when the browser receives raw HTML bytes from the server. It converts these bytes into characters (based on encoding), tokenizes them into HTML tokens (start tags, end tags, text content), and converts tokens into DOM nodes organized in a tree structure. This process is incremental -- the browser starts building the DOM as soon as it receives the first chunk of HTML and does not wait for the entire document. However, when the parser encounters a <script> tag without async or defer, it must pause DOM construction, download the script, and execute it before continuing, because JavaScript can modify the DOM via document.write() or other APIs.
CSS Object Model (CSSOM) construction follows a similar process: CSS bytes are converted to characters, tokenized, parsed into nodes, and assembled into a tree. Unlike DOM construction, CSSOM construction is render-blocking -- the browser cannot render anything until the entire CSSOM is built because CSS rules can override each other and the final computed styles depend on the complete stylesheet. This is why CSS is called a render-blocking resource. The browser must wait for all CSS to be downloaded and parsed before it can proceed to the next step.
The render tree is constructed by combining the DOM and CSSOM. The browser walks the DOM tree and for each visible node, finds and applies the matching CSSOM rules. Nodes that are not visible (elements with display:none, <head> content, <script> tags) are excluded from the render tree. Note that elements with visibility:hidden are included in the render tree because they still occupy space -- only display:none removes elements entirely. The render tree contains only the information needed for rendering: each node has its content and computed styles.
Layout (also called reflow) is the process where the browser calculates the exact position and size of each render tree node. Starting from the root, it traverses the tree and computes the geometry (position, width, height, margins) based on the viewport size, box model rules, and CSS properties. Layout is expensive because changing one element's geometry can affect its parent, siblings, and children -- a change can cascade through the entire tree. Repaint occurs when visual properties change without affecting layout (color, background-color, box-shadow, visibility). Repaint is cheaper than reflow but still not free.
Reflow and repaint are triggered by various operations and understanding what triggers each is crucial for performance. Properties that change geometry (width, height, margin, padding, position, font-size) trigger reflow followed by repaint. Properties that only change appearance (color, background, border-color, outline) trigger only repaint. Reading layout properties (offsetWidth, getBoundingClientRect(), scrollTop) can force a synchronous layout if the DOM has been modified since the last layout, creating a pattern called 'forced synchronous layout' or 'layout thrashing'. Compositing, the final step, combines painted layers into the final image. Transforms and opacity changes can be handled entirely on the GPU compositor thread without triggering layout or paint, which is why transform-based animations are far more performant than animating left/top.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- RENDER-BLOCKING: Browser waits for CSSOM -->
<link rel="stylesheet" href="styles.css" />
<!-- PARSER-BLOCKING: Stops DOM construction -->
<script src="blocking.js"></script>
<!-- NON-BLOCKING: Downloaded in parallel, executes after DOM -->
<script src="deferred.js" defer></script>
<!-- NON-BLOCKING: Downloaded in parallel, executes ASAP -->
<script src="async-script.js" async></script>
</head>
<body>
<!-- DOM construction continues here after blocking.js completes -->
<h1>Hello World</h1>
<p>This content waits for CSS + blocking JS.</p>
</body>
</html>
<!--
Rendering timeline:
1. Parser starts building DOM
2. Encounters <link> => starts downloading CSS (render-blocking)
3. Encounters <script> (no defer/async) => pauses DOM, downloads + executes JS
4. Encounters defer script => downloads in parallel, queued for later
5. Encounters async script => downloads in parallel, executes when ready
6. Parser resumes building DOM
7. CSS download completes => CSSOM built
8. DOM + CSSOM ready => Render tree constructed
9. Layout => Paint => Composite => Pixels on screen
10. defer scripts execute in order after DOM is complete
-->
CSS files are render-blocking (the browser cannot render until CSSOM is complete). Scripts without async/defer are parser-blocking (they stop DOM construction). The defer attribute downloads scripts in parallel but executes them in order after the DOM is fully parsed. The async attribute downloads in parallel and executes immediately when ready, regardless of DOM state.
// BAD: Layout thrashing (forced synchronous layout)
const items = document.querySelectorAll('.item');
items.forEach((item) => {
// READ: forces layout calculation
const width = item.offsetWidth;
// WRITE: invalidates layout
item.style.width = width * 2 + 'px';
// Next iteration: READ forces layout again!
});
// GOOD: Batch reads, then batch writes
const items2 = document.querySelectorAll('.item');
// Phase 1: Read all values
const widths = Array.from(items2).map((item) => item.offsetWidth);
// Phase 2: Write all values
items2.forEach((item, i) => {
item.style.width = widths[i] * 2 + 'px';
});
// BEST: Use requestAnimationFrame to batch DOM writes
function batchUpdate(elements) {
// Read phase (outside rAF)
const measurements = Array.from(elements).map((el) => ({
el,
width: el.offsetWidth,
height: el.offsetHeight,
}));
// Write phase (inside rAF)
requestAnimationFrame(() => {
measurements.forEach(({ el, width, height }) => {
el.style.width = width * 2 + 'px';
el.style.height = height * 2 + 'px';
});
});
}
Layout thrashing occurs when you interleave DOM reads and writes in a loop. Each read forces the browser to recalculate layout to return accurate values. The fix is to separate reads and writes into distinct phases: first read all values, then write all changes. Using requestAnimationFrame for writes ensures changes are batched before the next repaint.
/* REFLOW (Layout) triggers - most expensive */
/* These change geometry and cascade through the tree */
.reflow-triggers {
width: 200px; /* reflow */
height: 100px; /* reflow */
margin: 10px; /* reflow */
padding: 15px; /* reflow */
border-width: 2px; /* reflow */
font-size: 16px; /* reflow */
position: absolute; /* reflow */
display: flex; /* reflow */
top: 50px; /* reflow */
left: 100px; /* reflow */
}
/* REPAINT triggers - moderately expensive */
/* These change visual appearance without affecting layout */
.repaint-triggers {
color: red; /* repaint only */
background-color: blue; /* repaint only */
border-color: green; /* repaint only */
box-shadow: 0 2px 4px; /* repaint only */
outline: 1px solid; /* repaint only */
visibility: hidden; /* repaint only (still occupies space) */
}
/* COMPOSITE-ONLY triggers - cheapest, GPU-accelerated */
/* These skip layout and paint entirely */
.composite-triggers {
transform: translateX(100px); /* composite only */
transform: scale(1.5); /* composite only */
transform: rotate(45deg); /* composite only */
opacity: 0.5; /* composite only */
will-change: transform; /* promotes to own layer */
}
/* Prefer transform for animations instead of top/left */
/* BAD: triggers reflow on every frame */
@keyframes slide-bad {
from { left: 0; }
to { left: 300px; }
}
/* GOOD: composite-only, runs on GPU */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(300px); }
}
Understanding which CSS properties trigger which rendering phases is key to writing performant CSS. Composite-only properties (transform, opacity) are the cheapest to animate because they run on the GPU without triggering layout or paint. Always prefer transform-based animations over top/left/width/height animations.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- 1. Inline critical CSS to avoid render-blocking request -->
<style>
/* Only styles needed for above-the-fold content */
body { margin: 0; font-family: system-ui, sans-serif; }
.hero { padding: 2rem; background: #f0f0f0; }
.hero h1 { font-size: 2rem; margin: 0; }
</style>
<!-- 2. Load full CSS asynchronously -->
<link
rel="preload"
href="/css/full-styles.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/css/full-styles.css" />
</noscript>
<!-- 3. Preconnect to critical origins -->
<link rel="preconnect" href="https://api.example.com" />
<!-- 4. Defer non-critical JavaScript -->
<script src="/js/app.js" defer></script>
</head>
<body>
<!-- 5. Above-the-fold content renders immediately -->
<section class="hero">
<h1>Welcome</h1>
<p>This content renders without waiting for external CSS or JS.</p>
</section>
<!-- Below-the-fold content -->
<section class="features">
<p>Rest of the page loads progressively.</p>
</section>
</body>
</html>
This example shows four CRP optimizations: (1) inline critical CSS eliminates the render-blocking stylesheet request for above-the-fold content, (2) the full stylesheet is loaded asynchronously via preload with a rel swap, (3) preconnect eliminates connection latency for API calls, and (4) JavaScript is deferred to avoid blocking DOM construction.
DOM Construction, CSSOM, Render Tree, Reflow, Repaint, Layout, Compositing