Eliminating Render-Blocking CSS and JS
First Contentful Paint stalls for hundreds of milliseconds because a <script> in <head> freezes HTML tokenization and an external <link rel="stylesheet"> gates the render tree β both symptoms of resources the browser treats as render-blocking during document construction.
This guide is part of Critical Rendering Path Optimization, itself a topic under Browser Rendering Pipeline Fundamentals. Where the sibling guide on optimizing critical CSS for faster first paint focuses on shrinking the inlined payload, this one is about the ordering problem: getting the blocking resources out of the parserβs way so the render tree can assemble at least one round-trip sooner.
Reproducing the Stall
The smallest document that reliably delays first paint puts a synchronous script and a full stylesheet in the head, both discovered before any body content:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/css/app.css"> <!-- render-blocking: FCP waits for CSSOM -->
<script src="/js/analytics.js"></script> <!-- BAD LINE: parser-blocking, halts tokenizer -->
</head>
<body>
<h1>Product catalogue</h1>
<p>1,200 items in stock.</p>
</body>
</html>
The <h1> is trivial to lay out, yet it will not paint until app.css has been fetched and parsed into a CSSOM and analytics.js has been fetched, compiled, and executed. On a Slow 4G link with a cold cache, that is two serial network round-trips stacked in front of content the browser already holds in its input buffer.
Why the Parser Stops
When the HTML tokenizer, running on the main thread, reaches the <script> tag with no async or defer attribute, the parser transitions into a blocked state. The token stream is suspended, the script is fetched (if external), then handed to the V8 compiler and executed synchronously β because the script might call document.write() and mutate the very byte stream being tokenized, the parser cannot legally continue past it. Stylesheets block at a different point: the parser keeps building the DOM, but the browser refuses to run style calculation and cascade or paint until every stylesheet discovered so far has produced a complete CSSOM, since applying half a cascade would flash unstyled content. A subtlety that trips people up: a <script> placed after a <link rel="stylesheet"> is also stylesheet-blocked, because the script may read computed styles, so the pending CSS fetch now gates script execution too β chaining the two costs.
The one thing working in your favour is the preload scanner: a lightweight secondary parser that scans the raw byte stream ahead of the main tokenizer and kicks off resource fetches speculatively, even while the main parser is blocked. It is why a stylesheet often finishes downloading before the blocking script ahead of it runs. But the preload scanner only sees markup already in the buffer β resources injected later by JavaScript, or referenced by @import, are invisible to it and pay full serial latency.
Reordering the Critical Path
The fix is not to delete these resources but to reclassify them so they leave the critical path. A parser-blocking script becomes a defer script that runs after the DOM is complete; a full stylesheet is split into an inlined critical block plus a non-blocking remainder. The goal is a head that contains exactly the bytes needed to build a first render tree, and nothing that forces a serial fetch before it.
Before you touch anything, capture a baseline trace so you know which resources actually sit on the path. In DevTools the render-blocking chain reads cleanly off the Performance panelβs main-thread track:
[Main Thread β cold load, Slow 4G, 4x CPU]
ββ Parse HTML .............. 0β6ms
ββ (parser blocked) ........ 6β612ms β waiting on /js/analytics.js
β ββ Evaluate Script ..... 590β612ms analytics.js (fetch 6β588ms, exec 22ms)
ββ Parse HTML (resume) ..... 612β618ms
ββ Recalculate Style ....... 620β631ms β blocked until app.css CSSOM ready
β ββ Parse Stylesheet .... app.css (fetch overlapped via preload scanner)
ββ Layout .................. 631β639ms
ββ Paint ................... 641ms β FCP marker, ~635ms wasted on blockers
The 612ms gap is the whole story: almost none of it is CPU work, it is the main thread idling while a fetch it did not need to wait for completes. Reordering collapses that gap to near zero because the script no longer sits in the tokenizerβs path and the critical styles arrive inline with the HTML.
Deferring JS and Non-Blocking CSS
Each resource in the head gets one of a small set of treatments, chosen by whether it affects first paint. Scripts split into defer (execute in order after the DOM is parsed), async (execute whenever the fetch resolves, order not guaranteed), or module preloading; stylesheets split into inlined-critical versus a media-swapped non-blocking <link>. The decision is mechanical once you know whether the resource paints above the fold.
The complete rewrite of the reproduction, before and after, makes the classification concrete:
<!-- BEFORE: both resources sit on the critical path -->
<head>
<link rel="stylesheet" href="/css/app.css"> <!-- blocks paint until CSSOM built -->
<script src="/js/analytics.js"></script> <!-- blocks tokenizer until executed -->
</head>
<!-- AFTER: head carries only first-paint bytes -->
<head>
<style>/* extracted above-the-fold rules, kept under ~14KB */
h1{font:600 2rem/1.2 system-ui}body{margin:0}
</style>
<!-- non-critical CSS: fetched at low priority, applied after first paint.
media="print" means "not needed for screen render", so it never blocks;
onload flips it to all once it arrives -->
<link rel="stylesheet" href="/css/app.css" media="print"
onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/app.css"></noscript>
<!-- defer: fetched in parallel now, executed after DOMContentLoaded,
so it never suspends the tokenizer -->
<script src="/js/analytics.js" defer></script>
</head>
After the change the tokenizer runs to completion without a single suspension, the inlined <style> satisfies the paint gate with no network wait, and both the deferred script and the remainder stylesheet download in parallel off the critical path. If the script is genuinely independent of DOM order β a beacon or an error reporter β swap defer for async so it can execute the instant its fetch resolves. For scripts you know you will need soon but not for paint, pair a defer bundle with a <link rel="preload" as="script"> hint, the same non-blocking prefetch used for fonts in preventing FOUT and FOIT with font-display.
The pipeline-phase view of where each cost lands helps confirm nothing crept back onto the path:
| Pipeline phase | Blocking constraint | Cost if left on critical path |
|---|---|---|
| HTML tokenization | Synchronous <script> suspends the parser |
One full fetch + compile + execute, serial |
| CSSOM construction | Paint withheld until every discovered sheet parses | At least one network round-trip before FCP |
| Render tree build | Requires DOM + CSSOM merged | Blocked by the later of the two above |
| Script after stylesheet | Script waits on pending CSS to read styles | Chains the CSS fetch onto script execution |
Verification Checklist
Frequently Asked Questions
What is the difference between async and defer for a script?
Both fetch the script in parallel without suspending the HTML tokenizer. defer waits until the DOM is fully parsed and then executes deferred scripts in document order, right before DOMContentLoaded. async executes as soon as its own fetch resolves, in no guaranteed order relative to other scripts. Use defer for anything that touches the DOM or depends on load order, and async for self-contained scripts like analytics beacons.
Why does a stylesheet block rendering but not the DOM?
The parser keeps building DOM nodes while a stylesheet is in flight, so tokenization is not blocked. What is blocked is paint: the browser will not run style calculation or paint the render tree until every stylesheet it has discovered has produced a complete CSSOM, because applying a partial cascade would flash unstyled content. See why CSS blocks rendering until the CSSOM is built for the full mechanism.
Does the media="print" trick hurt users without JavaScript?
It can, because the onload handler that promotes the sheet to media="all" never fires without JavaScript. The standard guard is a <noscript> fallback containing a plain blocking <link> to the same stylesheet, so non-JS clients still get fully styled content at the cost of the normal render-blocking behaviour.
Will the preload scanner fetch a script injected by JavaScript?
No. The preload scanner only sees resource references already present in the raw HTML byte stream. A script element created with document.createElement and appended at runtime, or a stylesheet pulled in via @import, is invisible to the scanner and pays the full serial fetch latency. Reference such resources directly in the markup or with a <link rel="preload"> hint so the scanner can start the fetch early.
How do I know a script belongs off the critical path?
Ask whether it produces any pixels visible in the first paint. Frameworks that hydrate above-the-fold content may need to run early, but analytics, chat widgets, A/B tooling, and error reporters never do. Move those to defer or async and confirm with a trace that the main thread no longer shows a parser-blocked gap before FCP.
Related Guides
- Optimizing Critical CSS for First Paint β how to extract and size the inline block this guide relies on.
- Why CSS Blocks Rendering Until the CSSOM Is Built β the engine reason stylesheets gate first paint.
- Critical Rendering Path Optimization β the parent overview of every step between request and first pixel.
- How Browsers Parse HTML Into DOM Nodes β the tokenizer that a synchronous script suspends.