CSS Blocking vs Non-Blocking Resources
Why CSS Blocks Rendering
Here is something that trips up even experienced developers: CSS is render-blocking by default. The browser flat-out refuses to paint a single pixel until the entire CSSOM is constructed. Sounds aggressive, right? But it is not a design flaw — it is a deliberate choice to prevent a flash of unstyled content (FOUC).
Imagine painting a wall before deciding the color. You would have to strip the paint and redo it. The browser avoids this — it waits for all style information before committing pixels. Every <link rel="stylesheet"> in your <head> extends the wait time before first paint. The render-blocking behavior is the browser saying: "I will not paint until I know what everything looks like."
Let's see what this looks like in practice. Consider a page with two stylesheets:
<head>
<link rel="stylesheet" href="/base.css"> <!-- 50KB, 200ms -->
<link rel="stylesheet" href="/theme.css"> <!-- 30KB, 150ms -->
</head>
<body>
<h1>Hello World</h1>
</body>
The browser downloads both stylesheets in parallel (HTTP/2 multiplexing), but first paint cannot happen until both are fully downloaded and parsed. The CSSOM must be complete because a later stylesheet can override any rule from an earlier one — the browser cannot know the final computed styles until it has processed all CSS.
Conditional Blocking with the media Attribute
But wait — not all CSS is relevant in every context. Why should a print stylesheet block rendering on screen? The media attribute lets you tell the browser when a stylesheet actually applies:
<!-- Always render-blocking -->
<link rel="stylesheet" href="/main.css">
<!-- Only blocks rendering on screens wider than 768px -->
<link rel="stylesheet" href="/desktop.css" media="(min-width: 768px)">
<!-- Only blocks rendering when printing -->
<link rel="stylesheet" href="/print.css" media="print">
<!-- Never blocks rendering (media query evaluates to false on all screens) -->
<link rel="stylesheet" href="/print.css" media="print">
On a mobile device (viewport 375px), desktop.css with media="(min-width: 768px)" does not block rendering. The browser still downloads the file (it might be needed if the viewport changes), but it does not wait for it before painting.
The media attribute does not prevent download — it only controls whether the stylesheet blocks rendering. A media="print" stylesheet still downloads on screen devices. It is a priority hint, not a loading gate. The browser downloads it at a lower priority but still fetches it.
Async CSS Loading Patterns
And this is where it gets interesting. There is no async attribute for stylesheets like there is for scripts. So to load CSS without blocking rendering, you need to trick the browser:
The Preload + onload Pattern
<link rel="preload" href="/non-critical.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/non-critical.css"></noscript>
How it works:
rel="preload"fetches the file at high priority but does not apply it as a stylesheet- When the download completes,
onloadfires and switchesrelto"stylesheet" this.onload=nullprevents recursive firing in some browsers<noscript>fallback handles JavaScript-disabled environments
The media Hack
<link rel="stylesheet" href="/non-critical.css"
media="print" onload="this.media='all'">
The stylesheet loads as non-render-blocking (media="print" does not match screen). Once loaded, the onload handler switches to media="all", applying the styles without blocking initial render.
Script Loading: async, defer, and module
Now let's talk about scripts, because they interact with the parser in ways that catch people off guard. The behavior changes completely based on attributes:
<!-- Parser-blocking: stops HTML parsing, downloads, executes, then parsing resumes -->
<script src="/app.js"></script>
<!-- Async: downloads in parallel, executes as soon as downloaded (pauses parser briefly) -->
<script async src="/analytics.js"></script>
<!-- Defer: downloads in parallel, executes after HTML parsing is complete, before DOMContentLoaded -->
<script defer src="/app.js"></script>
<!-- Module: behaves like defer by default -->
<script type="module" src="/app.js"></script>
async scripts execute in download completion order, not document order. If a.js (200KB) and b.js (5KB) are both async, b.js will almost certainly execute first. If b.js depends on a.js, it will break. Use defer when execution order matters — defer scripts always execute in document order.
The CSSOM-JavaScript Dependency
This is the sneaky one. There is a hidden dependency that most developers never think about: JavaScript execution is blocked by pending stylesheets. Why? Because JavaScript can query computed styles (getComputedStyle(), offsetHeight, etc.), so the browser must ensure the CSSOM is complete before running any script.
<head>
<link rel="stylesheet" href="/styles.css"> <!-- 400ms download -->
<script src="/app.js"></script> <!-- 50ms download, but... -->
</head>
Even though app.js downloads in 50ms, it cannot execute until styles.css is fully loaded and parsed (400ms). The script waits for the CSSOM. This creates a hidden sequential chain: CSS download → CSS parse → JS execute → parser unblocked.
The preload scanner saves you (partially)
Modern browsers have a preload scanner (also called speculative parser). When the main parser is blocked by a script, the preload scanner continues scanning ahead in the HTML to discover resources and kick off early downloads. This is why app.js starts downloading in parallel with styles.css even though it cannot execute yet. Without the preload scanner, the browser would discover app.js only after the script blocking point — adding another round trip to the critical path.
Font Loading: FOIT and FOUT
Web fonts create a unique rendering headache. When the browser encounters text that needs a web font that has not loaded yet, it has to make a tough call:
- FOIT (Flash of Invisible Text): Hide the text until the font loads. Default in Chrome/Firefox (with a 3-second timeout).
- FOUT (Flash of Unstyled Text): Show the text in a fallback font, then swap when the font loads.
@font-face {
font-family: 'Inter';
src: url('/inter.woff2') format('woff2');
font-display: swap; /* FOUT — show fallback immediately, swap when ready */
/* font-display: block; FOIT — hide text for up to 3 seconds */
/* font-display: optional; Use font only if cached, never block or swap */
/* font-display: fallback; Short FOIT (100ms), then FOUT */
}
Resource Hints: The Complete Toolkit
<!-- 1. preconnect: Open connection early (DNS + TCP + TLS = ~200ms saved) -->
<link rel="preconnect" href="https://cdn.example.com">
<!-- 2. dns-prefetch: Only DNS lookup (~50ms saved, wider browser support) -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- 3. preload: Fetch specific resource now, high priority -->
<link rel="preload" href="/hero-image.webp" as="image">
<link rel="preload" href="/critical-font.woff2" as="font" type="font/woff2" crossorigin>
<!-- 4. prefetch: Fetch for next navigation, low priority -->
<link rel="prefetch" href="/next-page-data.json">
<!-- 5. fetchpriority: Override default priority -->
<img src="/hero.webp" fetchpriority="high">
<img src="/below-fold.webp" fetchpriority="low">
<script src="/critical.js" fetchpriority="high"></script>
Font preloads must include the crossorigin attribute even for same-origin fonts. Fonts are always fetched with CORS. Without crossorigin, the preloaded font and the font requested by @font-face are treated as different requests — resulting in a double download.
Putting It All Together: Optimal Resource Loading
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- 1. Early connection hints (before any resource URLs) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 2. Preload critical resources discovered late in the document -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image">
<!-- 3. Critical CSS inlined (no network request) -->
<style>/* above-the-fold styles */</style>
<!-- 4. Full CSS loaded async -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
<!-- 5. App script deferred -->
<script defer src="/app.js"></script>
<!-- 6. Analytics async (independent) -->
<script async src="/analytics.js"></script>
<!-- 7. Prefetch next page resources -->
<link rel="prefetch" href="/dashboard.js">
</head>
- 1CSS is render-blocking by default. The browser will not paint until all render-blocking stylesheets are fully parsed.
- 2Use the media attribute for conditional blocking — print.css and large-screen CSS should not block mobile rendering.
- 3Async CSS uses the preload+onload pattern or the media='print' hack to defer non-critical styles.
- 4Scripts without attributes block the HTML parser. Use defer for ordered execution after parse, async for independent scripts.
- 5JavaScript execution is blocked by pending stylesheets — CSS can indirectly block JS, creating hidden dependency chains.
- 6font-display: swap prevents FOIT. font-display: optional prevents layout shifts. Always preload critical fonts with crossorigin.
- 7preconnect saves ~200ms per new origin. preload fetches critical resources early. prefetch loads next-page resources at low priority.
- 8fetchpriority lets you override the browser's default resource prioritization for images, scripts, and fetch requests.
Q: Your team's LCP metric is 3.2 seconds. You've identified that a 150KB stylesheet and two web fonts are the primary bottleneck. Walk me through how you would optimize this.
A strong answer covers: inline critical CSS to unblock first paint, async-load the full stylesheet, preconnect to the font CDN origin, preload the fonts with crossorigin, use font-display: swap (or optional if CLS is a concern), consider self-hosting fonts to eliminate the extra origin connection, use the Coverage tab in DevTools to identify how much of the 150KB CSS is actually critical, consider splitting the CSS into critical/non-critical bundles. Bonus: mention HTTP 103 Early Hints to start font downloads before the HTML is ready.