Guide · Frontend
How Browsers Render a Page: The Critical Rendering Path
20 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The critical rendering path is the chain of steps a browser runs to turn HTML, CSS and JavaScript into the first pixels on screen. While one step waits for a file, the screen stays blank.
- First the browser reads HTML into the DOM (a tree of every tag) and CSS into the CSSOM (a tree of style rules). It joins them into a render tree of what will show, then works out box sizes (layout), draws pixels (paint) and stacks layers (composite).
- Two things usually hold it up. Render-blocking CSS: no paint until the stylesheets in the head that match the screen arrive. Parser-blocking scripts: a plain script tag stops the browser reading HTML until that script downloads and runs.
- The fixes are small and testable: inline a little critical CSS, add defer or async to scripts, and give images a width and height. Set font-display to swap so text shows in a fallback font while the web font loads.
- Beginners can stop after the layout and paint section. Engineers then get checks we ran in Chromium 141 (September 2025), the engine inside Chrome and Edge. They include a console snippet that lists blocking files, a font preload that downloads twice without crossorigin, and when LCP (the paint time of the biggest element) stops counting.
What goes wrong when a news page stays blank on a cheap phone?
The page stays blank because the browser has bytes but is not yet allowed to draw. It is waiting for files it was told it needs first, usually stylesheets, scripts and fonts.
Picture a common setup. A Hindi news site serves readers on phones that cost about Rs 6,000, often on a weak mobile signal. The team promises a headline on screen in under three seconds. The setup is made up, but the problem is real.
On a test phone over a slow connection, an article page stays white for about seven seconds (made up). The HTML is only 28 KB and the server answers fast. So the time goes somewhere between the bytes arriving and the pixels appearing.
The page source shows what most sites collect over the years:
- Three stylesheets in the head, 180 KB in total, though the first screen needs only a small part of them.
- Four script tags in the head with no async or defer: an ad loader, an analytics script, a photo slider library and the main app bundle.
- A Hindi web font of 140 KB, used for every headline.
- A large lead photo with no width or height written in the HTML.
Each item looks harmless alone. Together they make the browser wait, then wait again. To see why, you need the path the browser follows from bytes to pixels: the critical rendering path.
What is the critical rendering path?
The critical rendering path is the sequence of steps a browser follows to turn HTML, CSS and JavaScript into pixels on the screen. Critical means the first useful pixels cannot appear until these steps finish.
The path has six main steps. Each gets its own section below, so treat this as a map.
Build the DOM
The browser reads the HTML and builds the DOM (Document Object Model), a tree with one node for every tag and piece of text.
Build the CSSOM
It reads every stylesheet that applies and builds the CSSOM (CSS Object Model), a tree of the style rules for each node.
Make the render tree
It joins the DOM and CSSOM into a render tree, which keeps only what will be visible, each node with its final styles.
Layout
It works out the width, height and position of every box, for this screen size.
Paint
It fills in the pixels: text, colours, borders, shadows and images.
Composite
It stacks the painted layers in the right order and shows the result on screen.
The diagram shows the same path for one article page. Notice that two streams of work, HTML and CSS, must meet at the render tree before anything moves further.
From HTML bytes to pixels
Drag to exploreThe DOM and the CSSOM are built side by side. Nothing is painted until both reach the render tree.
Diagram connections:
- HTML bytes arrive to Build the DOM
- HTML bytes arrive to Stylesheets download (Finds stylesheet links)
- Stylesheets download to Build the CSSOM
- Build the DOM to Render tree
- Build the CSSOM to Render tree
- Render tree to Layout
- Layout to Paint
- Paint to Composite to the screen
- HTML bytes arrive: The article page, in chunks
- Build the DOM: A tree of every tag and text node
- Stylesheets download: Found through link tags in the head
- Build the CSSOM: Which style rule wins for each node
- Render tree: Only visible nodes, with final styles
- Layout: Size and position of every box
- Paint: Text, colours and images become pixels
- Composite to the screen: Layers stacked in the right order
Think of an old neighbourhood photo studio printing a roll of film. The negatives are the HTML: they hold what is in each picture. The order card the customer fills in, with sizes, finish and frames to skip, is the CSS.
The studio cannot print until both the negatives and the card are in hand. Layout is deciding where each print goes in the album, and paint is the printing itself.
Where the picture breaks: a studio gets the whole roll at once, but a browser gets HTML in chunks and starts the DOM before the file ends. And no customer's card can rewrite the negatives, while JavaScript can change the HTML as it loads.
How does the browser turn HTML into the DOM?
The browser's HTML parser reads the bytes in order and turns each tag into a node of the DOM tree. It works incrementally, so it starts before the whole file has arrived.
For the article page, the html tag becomes the root node. Inside it sit head and body. Inside body sit the menu, the headline, the lead photo and the story paragraphs. Every piece of text is a node too.
While the parser works, a helper called the preload scanner reads ahead in the raw HTML. It spots files the page will need, such as stylesheets, scripts and images, and starts downloading them early.
You can see it work. We tested in Chromium 141, the September 2025 release; current Chrome is 155 as of 19 October 2026, so re-check numbers on yours. In that test, a script in the head took 2 seconds to arrive and stopped the parser. An image further down still started downloading within 50 ms, long before the parser reached it.
Size matters here too. MDN, Mozilla's web documentation, notes that the more DOM nodes a page has, the longer DOM building and layout take. Every extra wrapper tag adds a node, on every page view.
There is also a network detail worth knowing. TCP is the protocol that carries HTTP/1.1 and HTTP/2. On a new TCP connection, the server sends a first burst, then waits for the browser to confirm it. A common upper limit for that burst is 10 packets, about 14 KB.
That limit comes from RFC 6928, an experimental RFC from 2013 that raised the allowed starting window to 10 segments, or 14,600 bytes for full-size packets. MDN advises fitting the HTML and CSS for the first render into those first 14 KB.
Why is CSS render-blocking?
CSS is render-blocking because a later rule can override an earlier one. So for stylesheets linked in the head, the browser will not paint until it has the full CSSOM. MDN describes it this way: rendering waits until all the CSS is received and processed. In our Chromium 141 test, a slow stylesheet linked lower in the body did not hold back the content above it.
Here is why that is a sensible default. Suppose the first stylesheet says headlines are black, and the third says article headlines are dark red. Painting early would show black text that flicks to red, a flash of wrongly styled content.
Note what CSS does not block. Waiting for CSS does not stop the HTML parser or other downloads. It does hold back a plain or inline script that comes after it, because scripts often ask what styles an element has.
You can check both halves. In our Chromium 141 test, a stylesheet was held back by 1.5 seconds. The parser still finished in under 0.1 seconds, but the first paint waited until about 1.6 seconds, just after the CSS arrived.
Two terms help here. Render-blocking means the browser will not paint until this file is handled. Parser-blocking means the HTML parser itself stops reading until this file is handled. CSS is the first kind; a plain script tag is the second.
A stylesheet that does not match the screen does not block
A stylesheet blocks rendering only when its media attribute matches the current screen. The media attribute on the link tag says when the file applies, such as print or screens at least 900 pixels wide.
The news team moves its wide-screen styles into their own file marked for screens at least 900 pixels wide. On a phone, the browser still downloads that file, at low priority, but paints without waiting for it.
Our test showed exactly that on a 360-pixel-wide window. The wide-screen file was marked non-blocking and arrived after about 2.5 seconds. The first paint came in under 0.4 seconds.
How does JavaScript block the HTML parser?
A plain external script tag, with no async, defer or type module, stops the HTML parser. The browser must download the script and run it before it reads one more line of HTML.
The reason is old but still true. A script can change the page as it loads, for example with document.write, which inserts new HTML at that exact spot. So the parser waits to see what the script does.
On the old article page the parser stops four times in a row. The preload scanner starts all four script downloads early, but they share one weak mobile connection. Each script also waits for the stylesheets above it, because CSS holds back the scripts after it. Follow the chain in the diagram.
Why the old page stayed white
Drag to exploreEach step waits for the one before it; downloads overlap but share one slow link. Times are on the test phone and made up; the order is what to notice.
Diagram connections:
- Parser reads the head to Three stylesheets load
- Three stylesheets load to Ad script waits for CSS
- Ad script waits for CSS to Three more scripts run
- Three more scripts run to Parser reaches the body
- Parser reaches the body to Wait for the Hindi font
- Wait for the Hindi font to First headline at 7.2 s
- Parser reads the head: First HTML bytes arrive at 0.6 s
- Three stylesheets load: 180 KB, all render-blocking
- Ad script waits for CSS: Already downloading; runs only after the CSS
- Three more scripts run: Analytics, slider, 310 KB app bundle
- Parser reaches the body: Headline enters the DOM at 5.1 s
- Wait for the Hindi font: Headline text stays invisible meanwhile
- First headline at 7.2 s: Over twice the three-second promise
The HTML standard gives you two attributes to change this. Both make the browser download an external script in parallel with parsing. They differ in when the script runs.
- async: the script runs as soon as it arrives, pausing the parser only while it runs. Several async scripts can run in any order.
- defer: the script runs after the parser has finished the whole page, before the DOMContentLoaded event. Deferred scripts run in the order they appear.
- Module scripts, marked with type module, are deferred by default unless they also have async. The standard says the defer attribute has no effect on them.
DOMContentLoaded is the event the browser fires when the HTML is fully parsed and deferred and module scripts have run. It does not wait for images or async scripts. The table puts the four kinds of external script side by side.
| Script tag | Download | When it runs | Order kept? | Stops the parser? |
|---|---|---|---|---|
| No attribute | Starts at once; the parser waits | As soon as it arrives | Yes | Yes, until downloaded and run |
| async | In parallel with parsing | As soon as it arrives | No | Only while it runs |
| defer | In parallel with parsing | After parsing ends | Yes | No |
| type module | In parallel with parsing | After parsing ends, by default | Yes | No |
Pause and predict: two async scripts take 900 ms and 100 ms to arrive, and two deferred scripts take 800 ms and 100 ms. Which order do they run in?
In our test, the fast async script ran first. The deferred scripts waited for the 800 ms one to arrive, then ran in page order, and DOMContentLoaded fired. The slow async script ran last, after DOMContentLoaded, because that event does not wait for async scripts.
What just happened? async gave up order to run early. defer kept order but ran only after parsing. That is the whole trade-off, and it drives the team's rule for each script.
The team's rule for each script in the head
Drag to exploreMost scripts never need to block the parser. The plain script tag becomes the rare exception.
Diagram connections:
- A script in the head to Used on this page?
- Used on this page? to Drop it from this page (No)
- Used on this page? to Needs DOM or other scripts? (Yes)
- Needs DOM or other scripts? to Mark it defer (Yes)
- Needs DOM or other scripts? to Mark it async (No)
- A script in the head: One of the page's four scripts
- Used on this page?: Asked separately for each page type
- Drop it from this page: Slider library only on photo pages
- Needs DOM or other scripts?: Reads the page or depends on a library
- Mark it defer: App bundle: runs in order after parsing
- Mark it async: Analytics and ads: independent
What happens in layout, paint and compositing?
Layout works out where every box goes and how big it is. Paint turns those boxes into pixels. Compositing stacks the painted layers in the right order on the screen.
First the render tree. It is built from the DOM and CSSOM together and keeps only what will show. The head and anything with display set to none are left out. An element with visibility hidden stays in, because it still takes up space.
Layout then measures. On a 360-pixel-wide phone, the headline box might be 340 pixels wide and three lines tall. The same headline on a laptop fits on one wide line.
The viewport meta tag tells a phone to lay the page out at its real width. In our test on an emulated 360-pixel phone, a page without the tag was laid out 980 pixels wide. With width=device-width, it was 360.
Any later layout pass is called reflow. Reflow happens when something changes size or position after the first layout, such as text added by a script or a photo whose size was unknown.
The lead photo had exactly that problem. The browser first laid out the story with no space for it. When the photo arrived, the browser learned its size and pushed every paragraph down, so readers lost their place.
Our test shows it. A photo with no width or height caused a layout shift, the browser's record of content jumping, scored 0.015. The same photo with width and height in the HTML caused none.
Paint comes next. The browser fills in text, colours, borders and images. MDN notes that after the first paint, browsers repaint only the areas that change. A colour change on a button needs a repaint but no reflow, so it costs less.
Finally, compositing. Some parts of a page, such as a sticky menu, may get their own layer. The browser paints each layer, then stacks them. MDN notes that layers speed up repaints but cost memory, so they should not be overused.
Beginners can stop here. You now know the steps, the two blockers and why layout jumps. The rest of the guide is for engineers: how to measure blockers on real pages, the fixes, and the details we checked in a browser.
How do you find what is blocking the first paint?
You find blockers by measuring on a slow device, then listing which files the browser marked as render-blocking and how long each took. Guessing usually picks the wrong file.
Start with the Performance panel in your browser's developer tools. Slow the network and the processor to match a cheap phone, then record a page load. The recording shows each parse, script run, layout and paint on a timeline.
For a quick list, the Resource Timing standard gives every downloaded file an entry. Its renderBlockingStatus field says blocking or non-blocking. As of 19 October 2026, MDN's browser data lists it only in Chromium-based browsers (Chrome and Edge 107 and later), not in Firefox or Safari.
// Paste into the DevTools console once the page has loaded.
if (!("renderBlockingStatus" in PerformanceResourceTiming.prototype)) {
console.log("This browser does not report renderBlockingStatus.");
}
const blockers = performance
.getEntriesByType("resource")
.filter((r) => r.renderBlockingStatus === "blocking");
for (const r of blockers) {
const ms = Math.round(r.responseEnd - r.startTime);
console.log(`Blocking: ${r.name} took ${ms} ms`);
}
console.log(`${blockers.length} render-blocking files`);The filter keeps only files the browser marked as blocking. Then responseEnd minus startTime gives how long each one took to download, in milliseconds. The first check stops a silent empty list in browsers without the field.
On our test copy of the old page in Chromium 141, it printed four Blocking lines with each file's full address. The three stylesheets took about 320 ms and the ad script about 220 ms, because our test server held them back by 300 ms and 200 ms. Then it printed 4 render-blocking files.
The four were the three stylesheets and the plain ad script. The async, deferred and print-only files were non-blocking.
Notice the ad script on that list. MDN's script page calls a plain script parser-blocking, not render-blocking. The HTML standard counts it as potentially render-blocking, and Chromium 141 labelled it blocking. Either way, nothing below it in the HTML can paint until it has run.
Measuring what real readers see with LCP
Your own test is one phone. To see what readers get, track Largest Contentful Paint, or LCP: the time when the largest image, text block or video in view is painted. On a news article, that is usually the headline or the lead photo.
web.dev, Google's guidance site, calls an LCP of 2.5 seconds or less good at the 75th percentile, meaning three in four page loads are at least that fast. As of 19 October 2026, browser data from MDN lists LCP in Chrome 77, Firefox 122 and Safari 26.2 onwards.
The browser reports LCP candidates as the page loads, and a later, bigger element replaces an earlier one. So the code keeps the latest entry and sends it when the tab is hidden. sendToStats stands in for your own reporting code.
declare function sendToStats(data: { lcpMs: number; tag: string }): void;
type LcpEntry = PerformanceEntry & { element?: Element | null };
let latest: LcpEntry | undefined;
new PerformanceObserver((list) => {
const entries = list.getEntries() as LcpEntry[];
latest = entries[entries.length - 1];
}).observe({ type: "largest-contentful-paint", buffered: true });
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && latest) {
sendToStats({
lcpMs: Math.round(latest.startTime),
tag: latest.element?.tagName ?? "unknown",
});
}
});The buffered option hands over LCP entries that happened before this code ran, so a late-loading script still sees them. The tag field says whether the headline or the photo was the largest element. It sends again each time the tab is hidden, so keep only the last report per page view.
We ran the compiled code in Chromium 141 after the page had loaded. It still saw the headline entry, painted in under 0.1 seconds. On leaving the page it sent that same time with tag H1.
Two details catch people out. LCP stops recording new candidates once the user taps, scrolls or presses a key. In our test, a bigger headline added after a click never became a new LCP entry.
web.dev also notes that Chromium skips low-entropy images, such as placeholders. In our test, a 115-byte grey SVG shown at 400 by 300 never counted, but a 1 KB flat grey PNG of the same size did.
The W3C, a web standards body, has an LCP draft dated 26 August 2026. It gives a rule: skip an image whose file has fewer bytes than 0.004 times the pixels it covers on screen. At 400 by 300, that line is 480 bytes.
Chromium 141 drew the line higher in our test. Flat grey SVGs of 749 bytes or less were skipped, and 750 bytes or more counted, about 0.05 bits per pixel. This is undocumented as of 19 October 2026, so pin your version and check which element your LCP entries name.
How do you optimise the critical rendering path?
You optimise it in three ways: send fewer critical files, make each one smaller, and start the important ones earlier. MDN gives the same three tips: defer or drop non-critical files, cut request count and size, and fetch critical files first.
Here is how the team applies that to the article page, one blocker at a time.
Inline the critical CSS
Write the 9 KB of CSS for the top of the article straight into a style tag in the head. Menu, headline and first paragraph now need no extra download.
Stop the rest of the CSS from blocking
Wide-screen and print styles get media attributes. A small deferred script adds the rest of the article CSS. A stylesheet added by a script does not block the first paint.
Fix every script tag
The app bundle gets defer. Analytics and the ad loader get async. The slider library leaves article pages and stays only on photo galleries.
Stop the font from hiding text
Set font-display to swap, so headlines show at once in the phone's own font, then switch. A subset with only the characters the site uses cuts the font to 38 KB.
Preload the font
A font is named only inside CSS, so the browser finds it late. A preload link in the head starts it early. It needs the crossorigin attribute, even on your own server.
Give the lead photo a size
Width and height in the HTML let layout reserve the space. The story no longer jumps down when the photo arrives.
Critical CSS means only the styles needed to draw the first screen, the part seen before scrolling. Inlining means putting that CSS inside the HTML instead of a separate file. It saves a round trip, a request to the server and back.
Keep the inline CSS small on purpose. Inlined CSS cannot be cached on its own, so every page view that fetches the HTML downloads it again.
What we checked for each fix
Each fix here has a test behind it in Chromium 141. A stylesheet added by a deferred script was marked non-blocking. The first paint came in about 0.1 seconds, while that stylesheet took about 2 seconds to arrive.
Fonts surprised us most. With the default font-display, the headline was still invisible 600 ms into a 4-second font download. It showed in the fallback font by 3.5 seconds. With swap, it showed at once.
The crossorigin rule is real too. A font preload without crossorigin made Chromium download the font twice, because the preload and the CSS request did not match. With crossorigin, it downloaded once.
| Measure | Before | After |
|---|---|---|
| Render-blocking CSS | 3 files, 180 KB | 0 files, 9 KB inline |
| Parser-blocking scripts | 4 | 0 |
| Hindi font | 140 KB, text hidden while loading | 38 KB, text shown at once |
| Headline first painted | 7.2 seconds | 1.9 seconds |
| Largest Contentful Paint | 8.4 seconds | 2.6 seconds |
| Story jumps when photo loads | Yes | No |
Notice that the total page weight barely changed. The scripts and full CSS still download. What changed is the order and what the browser waits for, so the headline no longer queues behind the ad loader.
The headline now meets the three-second promise. LCP, at 2.6 seconds, is still just above the 2.5 second good line. Here the lead photo is the LCP element, so it is the next thing to shrink.
Where server rendering fits
The news site's server already sends the full article as HTML, which is a big advantage. A page that sends an empty shell and builds the story in JavaScript puts a script download and run before the headline exists.
How do you explain the critical rendering path in a frontend system design interview?
Walk through the steps in order, name the two blockers, then show how you would measure and fix a real page. Interviewers want the mechanism and the trade-offs, not a list of tips.
Name the steps
HTML becomes the DOM, CSS becomes the CSSOM, both join into the render tree, then layout, paint and composite.
Explain the two blockers
CSS blocks rendering because later rules can override earlier ones. A plain script blocks the parser because it might change the page.
Give the script options
Compare async, defer and module scripts, and say which you would use for analytics and which for the main bundle.
Talk about measuring
Mention a throttled test, real reader numbers such as LCP, and which files the browser marks as render-blocking.
Fix one page out loud
Use an example like the news page: inline critical CSS, defer scripts, a swap font, image sizes. Give one reason for each.
Name the trade-offs
Inline CSS cannot be cached on its own. A swap font causes a brief change of font. Async scripts lose their order.
Questions people ask
- Does CSS block HTML parsing?
- No, CSS does not stop the HTML parser, but it does block rendering. The browser keeps building the DOM while stylesheets download. It will not paint until the matching stylesheets in the head are processed, and a plain or inline script after a stylesheet waits for it.
- Should I use async or defer for my scripts?
- Use defer for scripts that read the page or depend on each other, such as your main app bundle. Use async for independent scripts, such as analytics, that can run whenever they arrive. Both download without stopping the parser. Only defer keeps the order.
- What is the difference between reflow and repaint?
- Reflow recalculates the size and position of boxes, and repaint redraws pixels without moving anything. A reflow leads to a repaint, so it costs more. Changing a width or adding text causes a reflow. Changing only a colour causes just a repaint.
- What is the difference between DOMContentLoaded and the load event?
- DOMContentLoaded fires when the HTML is fully parsed and deferred and module scripts have run. It does not wait for images or async scripts. The load event fires later, once images, stylesheets, all scripts and iframes have finished, except lazy-loaded ones. A page can be readable well before load.
- What is critical CSS?
- Critical CSS is the small set of styles needed to draw the first screen of a page. You put it inside the HTML so the first paint needs no extra download. The rest of the CSS loads afterwards. Keep it small, because inline CSS is downloaded again with every page.
- Is the critical rendering path the same as page load time?
- No. The critical rendering path covers the steps up to the first useful pixels. Page load time also counts images further down, ads and late scripts. A page can show its headline in two seconds and still be loading other things at eight seconds.
- Does a frontend framework change the critical rendering path?
- No, the browser follows the same steps whatever tool built the page. A framework changes what the browser receives. If the HTML arrives empty and JavaScript builds the content, a script download and run sit in front of the first paint. Server rendering sends ready HTML instead.
Sources and further reading
- MDN Web Docs: Critical rendering path
- MDN Web Docs: Populating the page, how browsers work (preload scanner, 14 KB, render tree, layers, 16.67 ms)
- HTML Living Standard (WHATWG): Scripting, the async and defer attributes
- MDN Web Docs: The script element
- MDN Web Docs: DOMContentLoaded event
- MDN Web Docs: Window load event
- web.dev: Render-blocking CSS (media queries)
- MDN Web Docs: rel=preload (fonts need crossorigin)
- MDN Web Docs: font-display
- RFC 6928: Increasing TCP's Initial Window
- W3C Resource Timing (editor's draft), renderBlockingStatus
- W3C Largest Contentful Paint (editor's draft)
- web.dev: Largest Contentful Paint (2.5 second threshold)