Critical rendering path — what blocks it
Critical rendering path = HTML parse → CSSOM build → DOM + CSSOM merge into render tree → layout → paint. Blockers: render-blocking CSS (parser-blocking and render-blocking), parser-blocking sync `<script>` in head, large fonts (FOIT), heavy synchronous JS. Fixes: minimal critical CSS inline, `defer`/`async` scripts, font-display: swap, code split, preconnect.
The browser doesn't paint pixels until it has a render tree. Anything that delays the render tree blocks the first paint.
The path
HTML bytes → Tokenize → DOM
CSS bytes → Tokenize → CSSOM
DOM + CSSOM → Render Tree → Layout → PaintWhat blocks
- CSS is render-blocking by default. The browser won't paint until CSSOM is built.
<link rel="stylesheet">in<head>delays first paint. - CSS is also parser-blocking for JS that follows it (since scripts can query computed styles).
- Sync
<script>is parser-blocking — HTML parsing stops while the script downloads + executes. @importin CSS — sequential, each adds a roundtrip. Avoid.- Fonts — if not declared, the browser may show FOIT (flash of invisible text) until the font loads.
Fixes
Inline critical CSS
The CSS for above-the-fold content goes in <style> inline; the rest loads async:
<style>/* critical, < 14kb */</style>
<link rel="preload" as="style" href="main.css" onload="this.rel='stylesheet'">defer / async on scripts
<script defer src="app.js"></script> <!-- downloaded async, executed after parse -->
<script async src="analytics.js"></script> <!-- downloaded async, executed ASAP -->
<script type="module" src="..."></script> <!-- modules are defer by default -->defer preserves order; async doesn't. Use defer for app code, async for independent scripts (analytics).
Font loading
@font-face {
font-family: "Inter";
src: url("inter.woff2") format("woff2");
font-display: swap; /* show fallback immediately, swap when loaded */
}Plus <link rel="preload" as="font" href="inter.woff2" crossorigin> for above-the-fold fonts.
Preconnect / dns-prefetch
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="//cdn.example.com">Saves up to 200ms on cold connections — see [[dns-resolution-tcp-tls-the-request-lifecycle]].
Code splitting
Don't ship the whole app for the landing route. Lazy-import non-critical modules.
Tools
- Lighthouse flags render-blocking resources.
- DevTools Performance shows the rendering waterfall.
- WebPageTest filmstrip shows when first paint actually happened.
Interview framing
"Critical rendering path: HTML→DOM, CSS→CSSOM, combine into render tree, layout, paint. CSS is render-blocking by default; sync scripts in head are parser-blocking. Inline above-the-fold critical CSS, defer/async other scripts, use font-display: swap with preload for above-the-fold fonts, preconnect to third-party origins. Code split so first paint loads minimum. Measure with Lighthouse and DevTools Performance."
Follow-up questions
- •How do defer and async differ?
- •What's font-display: swap vs optional?
- •When does CSS block JS execution?
Common mistakes
- •Sync <script> in head.
- •@import inside CSS files.
- •Shipping all CSS upfront instead of route-specific.
- •No font-display, leading to FOIT.
Performance considerations
- •First paint is gated by CSSOM build + render tree. Every blocker costs LCP. Critical CSS inlining + async fonts + deferred scripts are the durable wins.
Edge cases
- •FOUC (flash of unstyled content) when CSS loads after HTML.
- •Critical CSS extraction tooling drift.
- •CDN regions affect preconnect benefit.
Real-world examples
- •Lighthouse audits, Next.js automatic critical CSS extraction, WebPageTest filmstrip analysis.