What is critical rendering path (crp) in browser
Critical rendering path (CRP) is the sequence of steps a browser takes to convert HTML, CSS, and JavaScript into visible pixels on screen, starting from the first byte of HTML and ending at the first painted frame.
Theory
TL;DR
- CRP works like a factory assembly line: HTML builds the frame (DOM), CSS adds styling (CSSOM), but a blocking
<script>halts the whole line until it finishes - Main goal: minimize time to First Contentful Paint (FCP); Chrome targets under 1.8s
- Steps in order: DOM construction → CSSOM → Render Tree → Layout → Paint → Composite
- Blocking resources (CSS files, synchronous JS) delay every step that follows them
- Decision rule: FCP over 1.8s on mobile? Start with
async/deferon scripts, then preload critical CSS
Quick example
<!DOCTYPE html>
<html>
<head>
<!-- Blocks parsing until downloaded and parsed -->
<link rel="stylesheet" href="styles.css">
<!-- Blocks parsing until downloaded and executed -->
<script src="script.js"></script>
</head>
<body>
<!-- Won't paint until CRP completes -->
<h1>Visible content</h1>
</body>
</html>The browser stops at <script>, downloads and runs script.js, then builds DOM and CSSOM, merges them into a render tree, calculates layout, and finally paints. Each blocking resource adds 200-500ms to FCP. You can see this directly in Chrome DevTools Network tab.
How the browser builds the CRP
Six steps, always in this order.
DOM construction. The browser parses HTML byte-by-byte into a tree of nodes. Chrome's Blink engine uses a pre-parser that speculatively fetches CSS and JS references while the main parser processes tokens. The <html>, <head>, <body> tags you write become Document, Element, and Text nodes.
CSSOM construction. CSS is render-blocking by design. The browser cannot display anything without knowing which styles apply to which nodes. Every <link rel="stylesheet"> triggers a fetch, and until that file downloads and parses, painting stops. @import inside a stylesheet makes this worse because it triggers a serial fetch, meaning files load one after another instead of in parallel.
Render Tree. DOM and CSSOM merge into a render tree that contains only visible nodes with computed styles. Elements with display: none are excluded entirely. Elements with visibility: hidden stay in but render as blank space.
Layout (Reflow). The browser calculates the exact size and position of every render tree node. This step is expensive when triggered repeatedly. Any JS that reads layout properties like offsetWidth after mutating the DOM forces a synchronous reflow.
Paint. The browser converts layout information into actual pixels, per layer. Text, borders, shadows, colors, all rasterized here.
Composite. Individual layers (especially those using CSS transform or opacity) get assembled by the compositor thread and sent to the GPU. This is why transform: translateZ(0) can push an element to its own layer and skip the paint step on animation.
Key difference: CRP vs full page load
Full page load includes every resource: below-fold images, lazy-loaded scripts, third-party widgets. CRP focuses only on what is needed to paint the first visible frame. Chrome can paint above-the-fold content in roughly 100ms if nothing blocks it. Waiting for the full page can mean 2+ seconds. That gap is exactly what CRP optimization targets.
When to optimize CRP
- FCP above 1.8s on mobile: preload critical CSS with
<link rel="preload">, inline it if under 10KB - JS-heavy SPA: add
asyncordeferto script tags to unblock parsing - Hero image above the fold: use
preloadwithfetchpriority="high" - CSS with
@import: replace with concatenated files via webpackMiniCssExtractPlugin - Legacy site: minify HTML and CSS, remove render-blocking resources
Common mistakes
1. Synchronous <script> in <head>
<!-- Blocks DOM parsing for the full download + execution time -->
<script src="app.js"></script>The browser stops parsing HTML the moment it hits this tag. A 1MB bundle on slow 3G can add a full second to FCP. Use defer for scripts that need DOM access, async for independent scripts like analytics.
2. CSS @import in a critical stylesheet
/* reset.css fetched serially, doubles CSS load time */
@import url('reset.css');The browser cannot discover reset.css until it finishes downloading and parsing the parent file. Lighthouse flags this. Bundle imports at build time instead.
3. Forced synchronous reflow in a loop
// Bad: from real dashboard code
for (let i = 0; i < 1000; i++) {
divs[i].style.height = '20px'; // Mutation
console.log(divs[i].getBoundingClientRect().height); // Forces reflow per iteration
}
// Result: 60fps drops to 5fps, FCP increases by ~2sIn production, this pattern quietly kills performance more often than any other CRP issue, especially in React portals and dashboard components that measure overlay dimensions. Fix: batch all mutations first, read layout properties after the loop.
4. Large inline <style> block above the fold
<style>/* 50KB of styles */</style>The parser waits for the full parse before moving on. Extract only critical above-fold CSS (under 14KB) and async-load the rest.
5. document.write() after initial load
document.write('<div>Dynamic content</div>'); // After DOMContentLoadedThis triggers a full CRP recalculation and briefly blanks the screen. Use element.appendChild() instead.
Real-world usage
- Next.js: automatically generates
<link rel="preload">for_app.jsand critical chunks, targeting sub-1s FCP - Gatsby:
gatsby-plugin-criticalsplits CSS and inlines above-fold styles, used across 100k+ sites with roughly 50% FCP improvement - Create React App: the default template uses
deferon the main bundle but ships a blockingmain.css; fix with thecrittersplugin for critical CSS extraction - Webpack:
HtmlWebpackPlugin+Crittersplugin inline critical CSS automatically in SPA builds - React portals: measuring overlay dimensions with
getBoundingClientRect()inside render loops is a known CRP performance issue in large dashboards
Follow-up questions
Q: What is the difference between CRP and LCP?
A: CRP is the full process from first byte to first painted frame. LCP (Largest Contentful Paint) is a Core Web Vital that measures how long the largest visible image or text block takes to appear. LCP above 2.5s is flagged as poor. CRP optimization usually improves LCP as a side effect.
Q: How do async and defer differ in their effect on CRP?
A: async downloads the script in parallel but executes it the moment download finishes, which can interrupt HTML parsing. defer also downloads in parallel but delays execution until after the DOM is fully parsed. For most app scripts, defer is the right choice. async works for scripts with no DOM dependencies, like analytics.
Q: How do you measure CRP steps in Chrome DevTools?
A: Open the Performance tab, record a page load, then filter for "Recalculate Style", "Layout", and "Paint" events. Each maps directly to a CRP step. Aim for under 100ms per frame. Yellow spikes in the flame chart usually point to forced reflows from JavaScript.
Q: How does a Service Worker affect CRP in a PWA?
A: The Service Worker intercepts fetch requests and can serve cached responses instantly, which speeds up CRP significantly on repeat visits. The stale-while-revalidate strategy returns cached HTML immediately while fetching an update in the background. The trade-off is that the first paint may show slightly stale content.
Q (senior level): You have a news site with 50+ hero images and user-specific CSS. How do you optimize CRP?
A: Preload the top 3 images with fetchpriority="high" so they compete for bandwidth first. Generate critical CSS per user segment server-side using PurgeCSS to strip unused rules. Lazy-load everything below the fold with loading="lazy". Measure with the web-vitals library via RUM data, not just Lighthouse. This approach reduced FCP by around 40% at high-traffic news sites.
Examples
Blocking vs non-blocking scripts
<!-- Version 1: blocks rendering -->
<head>
<script src="analytics.js"></script>
</head>
<!-- Version 2: async for scripts with no DOM dependency -->
<head>
<script src="analytics.js" async></script>
</head>
<!-- Version 3: defer for scripts that need DOM -->
<head>
<script src="app.js" defer></script>
</head>async lets the browser keep parsing HTML while the script downloads, but execution happens the moment the download finishes, which can still interrupt parsing mid-way. defer guarantees execution after the full DOM is ready. For analytics and third-party scripts with no DOM dependencies, async is fine. For your main app bundle, use defer.
Critical CSS extraction in a React app
<!-- public/index.html - unoptimized Create React App template -->
<head>
<!-- Render-blocking: delays FCP by ~300ms -->
<link rel="stylesheet" href="/static/css/main.abc123.css">
</head>
<body>
<div id="root"></div>
<!-- Good: defer keeps HTML parsing unblocked -->
<script src="/static/js/main.abc123.js" defer></script>
</body>Without critical CSS extraction, Lighthouse reports FCP around 2.5s for a typical CRA app. Adding the critters plugin to your webpack config inlines above-fold styles directly in <head> and async-loads the rest. FCP drops to under 1s on most connections.
The forced reflow loop trap
Any time you read a layout property immediately after mutating the DOM, the browser has to stop and recalculate layout synchronously before it can return the value.
// Bad: forces a reflow on every iteration
const divs = document.querySelectorAll('.item');
for (let i = 0; i < divs.length; i++) {
divs[i].style.height = '20px'; // Mutation
const height = divs[i].getBoundingClientRect().height; // Forces reflow
console.log(height);
}
// Good: batch mutations, then batch reads
for (let i = 0; i < divs.length; i++) {
divs[i].style.height = '20px'; // All mutations first
}
for (let i = 0; i < divs.length; i++) {
const height = divs[i].getBoundingClientRect().height; // All reads after
console.log(height);
}In a Chrome Performance trace, the bad version shows yellow "Layout" spikes after every style mutation and frame rate drops from 60fps to 5fps. The good version triggers layout once. Both versions produce the same output, so the bug is invisible without profiling.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.