Render-Blocking Resources
Three different mechanisms produce one symptom. CSS blocks painting, synchronous scripts block parsing, and scripts wait on pending stylesheets — and each has a different fix.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What exactly is the browser refusing to do while it waits for this resource, and why?
A person wants to read the page. The bytes of the text arrived a while ago; the browser is choosing not to show it to them yet.
Some resources block rendering. Add async or defer to the things in the head and the blocking goes away.
async and defer are attributes on script. Putting them on a link does nothing, and the stylesheet that was actually holding up your first paint keeps holding it up.
asyncanddeferare attributes onscript. Putting them on alinkdoes nothing, and the stylesheet that was actually holding up your first paint keeps holding it up.- You defer every script, first paint improves, and the page now flashes unstyled content because the deferred script was the one applying a theme class to
html. - You have no synchronous scripts at all and the page still paints late. The blocker is CSS, and CSS has no
defer. - You add a tiny inline script in the head to read a layout measurement, and it does not run until a large stylesheet halfway down the page has finished downloading — a file it never touches.
- Someone "fixes" the blocking by moving scripts to the end of
body, which stops them blocking the parser and starts them blocking on everything above them instead.
What is actually happening
In the browser, not in the framework.
- CSS blocks rendering. The browser has the DOM and could paint, but it will not paint content it may immediately have to restyle. So it holds the first frame until every render-blocking stylesheet has been fetched and parsed into the CSSOM (The CSSOM).
- Synchronous scripts block parsing. When the tokenizer reaches a classic
scriptwith noasyncordefer, it stops. The script may calldocument.write, so the parser cannot know what the rest of the tree looks like until the script has finished (Why a Script Tag Stops the Parser). - Scripts wait on pending stylesheets. Any script that could observe computed style — and the browser cannot know in advance which ones will — must not run while a stylesheet is still in flight, or it would read stale values. So script execution is deferred behind CSS it never mentions.
- These compose badly. A synchronous script after a stylesheet in the head means: parser stopped, waiting for the script, which is waiting for the CSS. One slow file, two stalled subsystems.
- A stylesheet whose
mediaattribute does not match the current environment is fetched at low priority and does not block rendering. That is the mechanism behind every "load CSS without blocking" trick. - Deferred and module scripts run after parsing completes, in document order;
asyncscripts run whenever they arrive, in no guaranteed order at all (`defer`, `async` and `type="module"`).
What this makes the browser do
And which of it is avoidable.
- Holding a completed DOM in memory while refusing to run style, layout and paint over it — real work already done and deliberately not shown.
- Keeping the preload scanner running past the blocked parser so subresources further down the document can at least be requested (The Preload Scanner).
- Fetching, decompressing and parsing every render-blocking stylesheet, including the rules that apply to nothing on this page.
- Compiling and executing blocking scripts on the main thread, which competes with nothing because nothing else can run — the cost here is entirely serialisation, not contention.
- Re-running style and layout for the whole document if a stylesheet arrives after a first paint has already happened (Style Invalidation).
Three mechanisms wearing one symptom
The symptom is always the same: content exists and the user cannot see it. The causes are unrelated, and conflating them is this lesson's reason to exist — it produces confident fixes aimed at the wrong subsystem.
Read the middle column carefully. Only one of these is about the painter. One is about the parser. One is about the script scheduler. An attribute that helps with one of them is inert against the others.
- 1CSS blocks rendering
The DOM may be complete; the browser holds the first frame until every render-blocking stylesheet is parsed, because it will not show content it may immediately restyle.
fails by A large stylesheet, or a small one on a cold third-party origin that must first be resolved, connected to and secured. There is no
deferfor this — the fixes are less CSS, a closer origin, or an honestmediaattribute. - 2Synchronous scripts block parsing
The tokenizer stops at the tag. The script might call
document.write, so the shape of the rest of the tree is unknowable until it has run.fails by A classic
scriptin the head with no attribute, usually for code that had no reason to run then. The fix isdeferortype="module"(`defer`, `async` and `type="module"`). - 3Scripts wait on pending stylesheets
A script that could read computed style must not run while a stylesheet is in flight, so execution is held even for an inline script that touches no CSS.
fails by The surprising one: a two-line inline script in the head delayed by a stylesheet it never references. The fix is to make the script independent of layout, or to get the stylesheet in earlier.
- 4The three compose
A blocking script after a blocking stylesheet in the head means the parser is stopped, waiting for a script, which is waiting for CSS.
fails by Each mechanism is cheap alone and expensive in series. This is why head order is worth reviewing as carefully as the resources themselves.
Only the first is about painting. Any fix that does not name which row it addresses is a guess.
What to write instead
The head of a document is a small amount of markup that decides most of the loading behaviour of the page, and it is almost never reviewed with that in mind. The pair below is the same page, twice.
The important part is not that the second version is shorter. It is that every line in it is a deliberate answer to "what must be true before the user sees correct content", and the first version had never been asked that question.
<head> <script src="https://tags.example.com/loader.js"></script> <link rel="stylesheet" href="https://fonts.example.com/family.css"> <link rel="stylesheet" href="/app.css"> <script src="/vendor.js"></script> <script src="/app.js"></script> </head>
<head>
<link rel="preconnect" href="https://fonts.example.com" crossorigin>
<link rel="stylesheet" href="/app.css">
<script>
/* Must run before paint: applies the stored theme so the
first frame is not the wrong one. No dependencies, so no
stylesheet can delay it. */
document.documentElement.dataset.theme =
localStorage.getItem('theme') || 'system'
</script>
<script type="module" src="/app.js"></script>
<script src="https://tags.example.com/loader.js" defer></script>
</head>The first version blocks the parser three times and the painter twice, including on a third-party origin nobody on the team controls. The second blocks the painter exactly once, on a same-origin stylesheet, and every script has been given an explicit answer to "must this run before content exists?". The inline script is the one genuine exception, and it is inline precisely so that no pending stylesheet can delay it.
1<!-- Genuinely not needed for the current view: fetched at low2 priority, does not hold the first paint. -->3<link rel="stylesheet" href="/print.css" media="print">4 5<!-- Also honest: this sheet only applies above a breakpoint the6 current viewport does not match. -->7<link rel="stylesheet" href="/wide.css" media="(min-width: 60rem)">8 9<!-- Dishonest: claims the sheet is for print, then switches it to10 everything the moment it loads. It does stop blocking. It also11 means the user watches the page restyle itself. -->12<link rel="stylesheet" href="/app.css" media="print"13 onload="this.media='all'">The third form is a real and widely copied technique. It is not wrong so much as unlabelled: it trades a correct first frame for an earlier one. Use it for CSS that genuinely affects nothing the user can currently see, and treat it as a visible regression anywhere else.
The one nobody believes: scripts waiting on CSS
This is the mechanism that produces bug reports nobody can reproduce. A tiny inline script in the head — feature detection, a class toggle, a measurement — appears to run late, and there is no async, no defer, no network request of its own to blame.
The reason is that the browser cannot know what a script will touch until it runs it. If a stylesheet is still in flight, any computed style the script reads would be wrong, so the script is held. An inline script is not exempt: being inline saves the fetch, not the wait.
The practical consequence is that "put it inline in the head so it runs first" is only true if nothing above it is a pending stylesheet, and only if the script itself does not read layout. Both conditions are easy to violate accidentally during a refactor that nobody thinks of as a performance change.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| First paint is late; no synchronous scripts anywhere | Blank page, network idle, main thread idle | Render-blocking CSS — probably on a third-party origin | Count origins on the critical path first, then bytes. preconnect if it must stay, self-host if it can move (Resource Hints). |
| HTML parsing shows a visible gap in the Performance panel | Content below a certain element appears much later | A parser-blocking classic script at exactly that point | Add defer, or type="module", unless it must run before paint — in which case inline it and keep it dependency-free. |
| An inline head script runs later than expected | Theme flash, or a measurement that is wrong on slow connections only | Held behind a pending stylesheet above it | Move it above the stylesheet, and remove any read of computed style or layout from it. |
| Page renders unstyled, then restyles | A visible flash on every cold load | A stylesheet was made non-blocking that the first screen actually needed | Revert the media trick for that sheet, or split out the rules the first screen needs and let those block. |
Two async scripts, intermittent failure | Reproduces on roughly one load in five, never locally | async guarantees no order; the dependent script sometimes runs first | Use defer for anything with an order dependency; async is only for genuinely independent code (`defer`, `async` and `type="module"`). |
| First paint improved, complaints did not | Metrics moved, users did not agree | The blocking was moved rather than removed — the page paints early and is not usable (Interaction Responsiveness) | Measure when the page becomes interactive, not only when it becomes visible. |
1<head>2 <link rel="stylesheet" href="/slow.css">3 4 <!-- Held until slow.css has arrived and been parsed, even though5 this script never mentions it. The browser cannot rule out6 that getComputedStyle would return a stale value. -->7 <script>8 document.documentElement.classList.add('js')9 window.__navHeight = document.documentElement.clientHeight10 </script>11</head>12 13<!-- versus -->14 15<head>16 <!-- Runs immediately: no stylesheet is pending yet. -->17 <script>18 document.documentElement.classList.add('js')19 </script>20 21 <link rel="stylesheet" href="/slow.css">22</head>The second version also gives up something: clientHeight read before the stylesheet lands would be a pre-CSS value. If a script genuinely needs post-CSS geometry, it genuinely has to wait — the goal is to know which of your scripts is in that category, and it is usually none of them.
How to build it
Most important first.
- Name the mechanism before choosing the fix. "This is render-blocking CSS" and "this is a parser-blocking script" lead to completely different edits, and the wrong edit is worse than nothing.
- Default every script to
deferortype="module". The exceptions — code that must run before first paint to avoid a visible flash — should be small, inline, and justified in a comment. - Keep render-blocking CSS to what the first screen needs, on an origin you already have a connection to (The Critical Rendering Path).
- Use
mediahonestly: a print stylesheet, or a stylesheet for a breakpoint that does not match, genuinely is not render-blocking and should say so (Media Queries Beyond Width). - Put the small amount of code that must run before paint — a theme class, a stored layout preference — inline in the head, and keep it free of dependencies so it cannot be delayed by a stylesheet.
- If a stylesheet must not block, decide explicitly what the user sees in the meantime. "Unstyled for a moment" is a design decision, not a build artefact.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A blocked first paint is a blocked accessibility tree in practice: there is nothing rendered to navigate, no landmarks, no headings, no focusable elements (The Accessibility Tree).
- A flash of unstyled content is not merely ugly. It can present text at a size and contrast the user cannot read, and it moves everything on the page when the styles land (Contrast, Colour and Motion).
- The inline script that applies a stored preference — reduced motion, high contrast, larger text — is an accessibility feature. Deferring it means the first frame ignores a user's explicit choice.
- When a script that was blocking finally runs and rearranges the page, focus and the screen reader's reading position must be considered. Silence is the wrong answer; so is stealing focus (Focus Management).
What can go wrong
- Deferring the script that prevents a flash of the wrong theme, so every visit begins with a bright flash for users who chose a dark interface.
- A
linkwith a non-matchingmediathat actually does affect the current view, producing a restyle the user watches happen. - An inline head script that reads
getComputedStyleoroffsetHeightand is therefore blocked behind every pending stylesheet, silently, with no error (Layout Thrashing). - Moving scripts to the end of
bodyand calling it fixed: they no longer block parsing of content above them, and they still execute later thandeferwould have, because they are discovered later. asyncon a script with dependencies. It runs when it lands, which is a different moment on every load, so the bug reproduces roughly one time in five.
- A deferred script and the parser race in a defined way — the script is guaranteed to run after parsing — but
asyncscripts race genuinely, and two of them can run in either order on the same page across two loads. - A stylesheet and an inline script race: whether the script observes final computed style depends on whether the sheet has landed, and that varies per load on a slow network.
- A late stylesheet and the first paint race, which decides whether the user sees a correct frame or an incorrect one followed by a correction.
- Inline scripts and inline styles are exactly what a Content-Security-Policy is designed to constrain, so the "put it inline to avoid blocking" fix and a strict policy have to be reconciled with nonces or hashes (Content Security Policy).
- A render-blocking resource from a third-party origin gives that origin the ability to delay your page indefinitely — a availability dependency, not just a privacy one (Third-Party Scripts and the Supply Chain).
- Subresource integrity on a blocking third-party file converts a silent substitution into a loud failure. On the critical path that is usually the trade you want.
- A synchronous third-party script in the head executes with full access to the document before any of your own code has run, which is the strongest position on the page to be in.
- "
deferfixes render-blocking." It fixes parser-blocking. Render-blocking CSS is a different mechanism with no equivalent attribute. - "Scripts at the bottom of the body are the same as
defer." They are discovered later and therefore requested later;defergets the download started early and the execution late, which is strictly better. - "An inline script cannot be delayed." It can, by any stylesheet still in flight above it, because the browser cannot rule out that it will read computed style.
- "Render-blocking is bad." It is the browser preventing a visible restyle. The goal is a short blocking phase, not the absence of one.
Measuring it, and what changes in the field
- The Network panel marks render-blocking status per request and shows priority, which is the browser telling you which mechanism it thinks applies (Debugging the Network).
- The Performance panel shows the parser gap directly: a stretch where HTML parsing stops and script evaluation runs is a parser-blocking script, drawn.
- A first-paint marker sitting well after the last render-blocking stylesheet finished points at the third mechanism — script waiting on CSS — rather than at the CSS itself.
- Audit tooling lists render-blocking resources explicitly, which is a good inventory and a poor diagnosis: it will not tell you which of the three mechanisms is costing you the most (Measure Before Optimising).
- On a high-latency link, blocking costs a full round trip per blocked resource, and composed blocking costs them in series.
- On a slow device, script compilation and execution dominate, so a parser-blocking script is expensive even when its download was instant (The Real Cost of JavaScript).
- On a repeat visit with a warm cache, the same blocking structure costs almost nothing — which is why this class of bug survives so long in codebases whose authors are all returning visitors.
- With a service worker serving from cache, blocking resources can resolve without touching the network at all, changing the calculus completely (Intercepting Fetch).
- Making CSS non-blocking trades a correct first frame for an earlier one. Sometimes that is right; it is never free.
- Inlining critical CSS or a preference script grows every HTML response and cannot be cached separately (Browser HTTP Caching).
defereverywhere means nothing runs before parsing finishes, which is correct for almost all code and wrong for the small amount that must not be.- Splitting stylesheets so that only the blocking part blocks adds build complexity and a new way to be wrong about which rules the first screen needs.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALAll three mechanisms are specified behaviour and hold across engines. What varies is the heuristic layer on top — how aggressively each browser speculates past a blocked parser, and how it prioritises the resources it discovers there — which changes timing but never changes which resource is blocking.
- SPEC-EVOLVINGThe platform has been growing explicit control over this: a render-blocking attribute lets an element opt into or out of blocking rather than inferring it from element type. Support and exact semantics differ between engines and are still moving, so treat it as an enhancement over the default behaviour rather than a replacement for understanding it.
- NETWORK-SPECIFICOn HTTP/1.1 a blocking resource also consumed one of a handful of connections, so it delayed unrelated requests too; on HTTP/2 and HTTP/3 it blocks rendering without starving the connection, which makes the same markup meaningfully less costly than it used to be.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.