Frontend
Web Performance: Critical Rendering Path & Core Web Vitals
How a browser turns bytes into pixels, what blocks it along the way, and how LCP, CLS, and INP measure whether the result actually feels fast.
"The page feels slow" is a user's complaint, not an engineering one. To fix it, you need to translate that feeling into something measurable — and that means understanding what the browser is actually doing between receiving your HTML and putting pixels on the screen, and which of the numbers it can report back actually correlate with how a human perceives speed.
The critical rendering path
Every page load walks the same sequence of steps, regardless of framework. The browser receives an HTML response and starts parsing it into the DOM — a tree of nodes representing the document's structure. As it encounters <link rel="stylesheet"> tags, it fetches and parses CSS into the CSSOM, a parallel tree of every matched style rule.
Once both trees exist, the browser combines them into a render tree — only the nodes that will actually be visible, each annotated with its computed styles. From there it runs layout (sometimes called reflow): walking the render tree and computing the exact size and position of every element in pixels. Only then does paint happen — filling in pixels for text, colors, borders, and images — followed by composite, where the browser assembles painted layers (often on the GPU) into the final image you see on screen.
DOM → CSSOM → render tree → layout → paint → composite. Every performance optimization you'll ever make is really an attempt to shorten one of these steps, skip it, or move it off the main thread.
What blocks the path
Two things routinely stall this pipeline before it even reaches layout. The first is render-blocking CSS: the browser refuses to paint anything until it has parsed all the CSS it knows about, because any rule — even one at the bottom of a stylesheet — could restyle elements at the top of the page. There is no safe way to paint early and correct it later, so the browser simply waits.
The second is a blocking, synchronous <script> tag. When the HTML parser hits one, it has to stop parsing the document entirely, fetch the script if it isn't inline, execute it top to bottom, and only then resume — because that script might call document.write() or otherwise mutate the DOM the parser hasn't built yet.
The async and defer attributes exist to opt out of that penalty. async lets the script download in parallel with parsing and then executes it the moment it arrives, pausing the parser at that point — fine for independent scripts like analytics, but risky if execution order matters. defer also downloads in parallel, but guarantees execution happens only after parsing finishes and in document order — the right default for scripts that touch the DOM.
Why the order matters
Put your CSS in <head> so it starts downloading as early as possible — you can't avoid the block, but you can shrink it by starting the fetch sooner. Put non-critical scripts at the end of <body> or mark them defer, so they never compete with the render-blocking resources for bandwidth or main-thread time.
The three Core Web Vitals
Google's Core Web Vitals distill the rendering path into three metrics, each targeting a different moment a user actually notices.
Largest Contentful Paint (LCP) measures how long it takes the largest visible content element — usually a hero image, a video poster frame, or a large block of text — to render within the viewport. It's a proxy for "how long until this page feels loaded," and a good score is under 2.5 seconds.
Cumulative Layout Shift (CLS) measures how much visible content unexpectedly shifts position during the page's lifetime, weighting each shift by how much of the viewport moved and how far. It's a proxy for stability — nothing is more annoying than a button you were about to tap sliding down the screen because an ad just loaded above it. A good score is under 0.1.
Interaction to Next Paint (INP) measures how responsive the page is: the time from a user interaction — a click, a tap, a keypress — to the next frame the browser paints reflecting that interaction's visual result. INP replaced First Input Delay (FID) as Google's official responsiveness metric in 2024, because FID only measured the delay before an event handler started running, while INP accounts for the full cost of handling the interaction — including slow handlers and the work they queue afterward. A good score is under 200 milliseconds.
LCP: common cause and fix
LCP usually suffers from one of two problems: a slow server response that delays everything downstream, or a large, unoptimized hero image that takes too long to download and decode. The fix is to attack both ends — serve HTML fast (edge caching, a CDN, server-side rendering that doesn't block on slow data fetches), and make the LCP resource itself cheap to get: compress and correctly size images, serve modern formats like WebP or AVIF, and preload the image so the browser starts fetching it immediately instead of discovering it only after parsing the DOM down to that <img> tag.
<!-- Tell the browser about the LCP image before it discovers -->
<!-- it the normal way (parsing the CSSOM, then the DOM node) -->
<link
rel="preload"
as="image"
href="/hero.webp"
fetchpriority="high"
/>
<img
src="/hero.webp"
width="1600"
height="900"
alt="Product hero"
/>CLS: common cause and fix
CLS spikes almost always trace back to content appearing where nothing occupied that space a moment ago: an <img> without explicit width and height attributes that only takes up its true size once it finishes loading, or a cookie banner, ad slot, or "sign up" prompt that injects itself into the layout after the initial render and pushes everything below it down. The fix is to reserve the space before the content arrives — set explicit dimensions or an aspect-ratio on images and embeds, and give dynamically injected elements a fixed-height container from the start rather than letting them expand into the flow.
INP: common cause and fix
A bad INP score means the main thread is busy when the user interacts with the page — typically a long-running JavaScript task (a big state update, an expensive re-render, an unthrottled scroll or input handler) that can't be interrupted to paint the result of a click until it finishes. The fix is to break long tasks into smaller chunks that yield back to the browser between them (scheduler.yield() or simply splitting work across setTimeout/microtask boundaries), and to defer non-critical work — analytics, prefetching, non-visible computation — off the critical interaction path entirely, often using a Web Worker so it never touches the main thread at all.
Don't chase the wrong number
These three metrics are field metrics — they're measured on real users' real devices and networks, not a single lab run. A page that scores well in DevTools on a fast machine can still fail Core Web Vitals in the field on a mid-range phone over 4G. Always validate against real-user data (Chrome UX Report, or your own RUM), not just synthetic Lighthouse runs.
Why these metrics, specifically
None of this is arbitrary. LCP, CLS, and INP were chosen because each maps directly onto something a user actually perceives, in order: is the page fast to show me something meaningful (LCP), does it stay put once I start reading or aiming for something (CLS), and does it respond when I act on it (INP)? A page can be technically fast — quick server response, small bundle — and still feel slow if it fails any one of these. That's the whole point of the Core Web Vitals: they measure the experience of speed, not just the existence of it.
Key takeaways
- The critical rendering path is DOM → CSSOM → render tree → layout → paint → composite; every optimization shortens, skips, or reschedules one of these steps.
- Render-blocking CSS and synchronous scripts stall the path — async/defer let scripts download without blocking parsing.
- LCP measures load speed, CLS measures visual stability, and INP measures interaction responsiveness — INP replaced FID in 2024.
- Fix LCP by speeding up the server response and preloading/optimizing the largest visible element.
- Fix CLS by reserving space up front for images and dynamically injected content.
- Fix INP by breaking up long main-thread tasks and deferring non-critical work off the interaction path.
