Skip to content

CSS Blocking vs Non-Blocking Resources

advanced16 min read

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).

Mental Model

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.

Quiz
Two stylesheets load in parallel. The first takes 200ms, the second takes 400ms. When is the earliest the browser can paint?

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 browser downloads all stylesheets regardless

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.

Quiz
A stylesheet has media="(min-width: 1024px)" and the viewport is 768px. What happens?

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:

  1. rel="preload" fetches the file at high priority but does not apply it as a stylesheet
  2. When the download completes, onload fires and switches rel to "stylesheet"
  3. this.onload=null prevents recursive firing in some browsers
  4. <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>
Execution Trace
No attribute
Parser encounters `<script src>`
Parser halts → script downloads → script executes → parser resumes. Full blocking.
async
Parser encounters `<script async src>`
Parser continues → script downloads in parallel → when ready, parser pauses, script executes → parser resumes
defer
Parser encounters `<script defer src>`
Parser continues → script downloads in parallel → parser finishes → scripts execute in document order → DOMContentLoaded fires
module
Parser encounters `<script type='module' src>`
Same as defer. Modules are deferred by default. Adding async to a module makes it execute immediately when ready.
Common Trap

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.

Quiz
You have three scripts: polyfill.js (must run first), app.js (depends on polyfill), and analytics.js (independent). Which loading strategy is correct?

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 */
}
Quiz
A custom font takes 2 seconds to load. With font-display: swap, what does the user see?

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>
crossorigin is required for fonts

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.

Quiz
You preload a font with <link rel="preload" href="/font.woff2" as="font" type="font/woff2">. The font downloads twice. Why?

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>
Key Rules
  1. 1CSS is render-blocking by default. The browser will not paint until all render-blocking stylesheets are fully parsed.
  2. 2Use the media attribute for conditional blocking — print.css and large-screen CSS should not block mobile rendering.
  3. 3Async CSS uses the preload+onload pattern or the media='print' hack to defer non-critical styles.
  4. 4Scripts without attributes block the HTML parser. Use defer for ordered execution after parse, async for independent scripts.
  5. 5JavaScript execution is blocked by pending stylesheets — CSS can indirectly block JS, creating hidden dependency chains.
  6. 6font-display: swap prevents FOIT. font-display: optional prevents layout shifts. Always preload critical fonts with crossorigin.
  7. 7preconnect saves ~200ms per new origin. preload fetches critical resources early. prefetch loads next-page resources at low priority.
  8. 8fetchpriority lets you override the browser's default resource prioritization for images, scripts, and fetch requests.
Interview Question

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.