Eliminating Render-Blocking Resources on a Static Site
How to eliminate render-blocking CSS on a static site: measure the real cost, inline critical CSS without rotting your build, defer JavaScript safely.
: one 214KB stylesheet, a Google Fonts link, a jQuery bundle without defer`, and a tag manager snippet. First Contentful Paint was 2.9s on a throttled 4G profile, and the HTML had finished downloading at 310ms.
That gap is what render-blocking CSS and parser-blocking scripts actually cost you. Not bandwidth. Round trips and sequencing.
Here’s the part the listicles skip: the fix is not “inline your critical CSS and defer everything”. Half the sites I’ve audited that already did that were slower than if they’d left it alone, because they inlined 60KB into every page, broke their jQuery init order, and shipped a build step nobody understood six months later.
- A stylesheet in the head blocks first paint; a stylesheet with
media="print"does not. That one attribute, plus anonloadswap, removes most render-blocking CSS without a build step. - Below roughly 40KB of compressed CSS on a same-origin HTTP/2 connection, critical CSS extraction typically buys you under 100ms and costs you a fragile build step. Measure before you commit to it.
deferon your scripts does nothing if your init code is still an inline<script>in the body. That ordering bug is the single most common breakage when speeding up template-based sites.- Self-host fonts, preload only the woff2 files used above the fold, and override fallback metrics with
size-adjustso the swap doesn’t shift layout and wreck CLS. - Lock the win in with a Lighthouse CI budget on stylesheet bytes and FCP. Without it, the next stakeholder request puts a 90KB carousel stylesheet back in the head.
What actually blocks the render, and what only looks like it does
The browser needs a complete CSSOM before it paints anything, because a rule arriving later could restyle what it already drew. Any <link rel="stylesheet"> without a non-matching media attribute halts first paint until that file lands and parses. That’s the whole mechanism.
Scripts are a different problem. A classic <script src> blocks the HTML parser, which blocks DOM construction, which delays everything downstream including stylesheet discovery. Worse, a script can call document.write, so the parser cannot safely continue. Add defer and the parser keeps going; the script runs after parsing, in document order, before DOMContentLoaded. Add async and it runs whenever it arrives, out of order.
Three things people flag as render-blocking that aren’t:
- Images. They never block paint. A hero image delays LCP, which is a different audit and a different fix (
fetchpriority="high", correct sizing, and a format that isn’t a 900KB PNG, which is also where AI-generated hero images quietly go wrong). - Deferred scripts. Lighthouse won’t count them, though they still compete for bandwidth and main thread time.
- Stylesheets with
media="print"ormedia="(min-width: 1200px)"on a phone. The browser downloads them at low priority and doesn’t wait.
The preload scanner matters here too. It runs ahead of the parser, finds <link> and <img src> and <script src>, and starts fetches early. It cannot see resources injected by JavaScript. Every time you load CSS through a JS loader, you hide it from the scanner and usually end up net slower.

Measure the real cost before you change anything
Lighthouse gives you a wasted-milliseconds estimate per blocking resource. Treat it as a ranking, not a number. It’s modelled, not measured.
Do this instead, in order:
- Run the page on WebPageTest with a Moto G4 / 4G profile, three runs, and look at the filmstrip and the waterfall together. You want to know whether first paint is waiting on a round trip or on bytes.
- Open DevTools, Coverage tab, reload, and record unused CSS on your two or three highest-traffic templates. Unused bytes are not automatically removable, but if 88% of a 214KB sheet is unused on the homepage you’ve found your problem.
- Throttle CPU to 4x and watch the Performance panel for long parse and style-recalc tasks. On low-end Android, parsing a very large stylesheet is real work, not just transfer time.
The number I care about is the delta between HTML response complete and First Contentful Paint. On that client site it was 2.59s. After the work below it was 410ms, and the biggest single win (1.1s) came from removing the Google Fonts stylesheet, not from critical CSS.
Fixing render-blocking CSS: critical CSS and when it backfires
Start with the cheap move. Split your stylesheet by usage and demote everything that isn’t needed for the first screen:
<!-- blocking, small, covers the first viewport -->
<link rel="stylesheet" href="/css/core.css">
<!-- non-blocking: media doesn't match, so paint doesn't wait.
onload flips it to all, then clears itself so re-fires can't loop -->
<link rel="stylesheet" href="/css/components.css" media="print"
onload="this.media='all';this.onload=null">
<noscript><link rel="stylesheet" href="/css/components.css"></noscript>
This is the loadCSS pattern, minus the polyfill nobody needs in 2026. It costs you nothing at build time and it works in every browser you support. The trade-off is a flash of unstyled components if components.css arrives late, so only demote rules for things below the fold: accordions, modals, footers, carousels past the first slide, the mega menu dropdown panels (the hover styles, not the bar itself, which is exactly the kind of distinction that navigation patterns make or break).
If core.css is still big, extract critical CSS. Critters was archived; the maintained fork is Beasties, and it runs as a post-build step over static HTML:
// scripts/critical.mjs — run after your static build
import { readFile, writeFile } from 'node:fs/promises';
import { globby } from 'globby';
import Beasties from 'beasties';
const beasties = new Beasties({
path: 'dist', // resolve <link href> against the built output
preload: 'swap', // leaves the full sheet loading non-blocking
pruneSource: false, // never rewrite the shared stylesheet itself
reduceInlineStyles: false, // don't touch author inline styles
logLevel: 'warn'
});
for (const file of await globby('dist/**/*.html')) {
const html = await readFile(file, 'utf8');
await writeFile(file, await beasties.process(html));
}
Beasties works from static analysis of the HTML, so it misses rules that only apply after JS runs or after scroll. If your above-the-fold area depends on a class added by JavaScript, add it to allowRules or Penthouse’s forceInclude list. Penthouse is the better tool when you need an actual headless viewport and multiple breakpoints; it’s slower and needs Puppeteer.
Here’s the position worth defending: most static sites should not do this. If your blocking CSS is under about 40KB over the wire, same origin, HTTP/2 or HTTP/3, the file arrives in the same window as the HTML and you’re saving one already-warm round trip. You trade maybe 60 to 100ms for a build step that silently rots the moment someone edits a template and forgets to rerun it. I’ve inherited three sites where the inlined critical CSS was eleven months stale and actively wrong.
Inlining also isn’t free. Inline bytes are never cached. Inline 14KB into every page, and a visitor reading five pages pays 70KB instead of 14KB plus four cache hits. Critical CSS wins on landing pages with high bounce and cold caches. It loses on documentation sites and multi-page browsing. Know which one you have.
Things that are usually a mistake
- Loading CSS via JavaScript (a
<script>that appends a<link>). Invisible to the preload scanner, dependent on a parse-and-execute step, and broken if the script 404s. - HTTP/2 push. Removed from Chrome in version 106. Use
rel=preload, or103 Early Hintsif your CDN supports it (Cloudflare and Fastly do). - Preloading everything. Preload competes for bandwidth at high priority. Preload the two things that are discovered late and genuinely needed. Three is already suspicious.
How to defer JavaScript without breaking the page
On template-based sites this is where things explode. The template ships jquery.js and plugins.min.js in the head or before </body>, then every page has inline $(function(){ ... }) blocks sprinkled through the markup. Add defer to the library tags and those inline blocks now run first, $ is undefined, and your slider is gone.
Correct order of operations:
<!-- deferred scripts execute in document order, after parsing, before DOMContentLoaded -->
<script defer src="/js/jquery.min.js"></script>
<script defer src="/js/plugins.min.js"></script>
<script defer src="/js/functions.js"></script>
<!-- all page init code moved out of inline blocks into here -->
<script defer src="/js/init.js"></script>
Move every inline init into that last file, keyed by a selector or a data- attribute on the page so one file can serve all templates. If you cannot remove an inline block, queue it:
<script>
// runs immediately, pushes work for later; init.js drains this queue
(window.__init = window.__init || []).push(function ($) {
$('#testimonials').owlCarousel({ items: 1, loop: true });
});
</script>
// init.js, last deferred script
(window.__init || []).forEach(fn => fn(window.jQuery));
Use type="module" where you can; modules are deferred by default and you get real imports instead of load-order archaeology. Use async only for genuinely independent scripts with no dependents, which in practice means analytics and almost nothing else. If a vendor script is 180KB of chat widget, load it on interaction instead:
// load the widget on first real intent, not on page load
const boot = () => import('/js/chat-widget.js');
['pointerdown', 'keydown'].forEach(evt =>
addEventListener(evt, boot, { once: true, passive: true })
);
// safety net for users who never interact but scroll to the footer
new IntersectionObserver((e, o) => e[0].isIntersecting && (boot(), o.disconnect()))
.observe(document.querySelector('footer'));
One caveat on contact forms: if you’ve replaced a PHP mailer with a hosted endpoint such as WebForms, the submit handler must exist before the user can click. Don’t lazy-load form JS on interaction, or your first click gets a native page reload. Load it with defer and keep the <form> with a real action so it degrades.
Fonts are the render-blocker everyone forgets
A <link> to fonts.googleapis.com is a blocking stylesheet on an third-party origin: DNS, TLS, request, response, and only then does the browser learn which woff2 files it needs. That’s two serialised round trips before first paint on a cold connection. Self-host. It’s 2026 and there’s no caching benefit left to lose; cross-site HTTP cache partitioning killed that years ago.
<link rel="preload" href="/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: "Inter";
src: url("/fonts/inter-400.woff2") format("woff2");
font-weight: 400;
font-display: swap; / paint in the fallback immediately /
}
/* Metric-matched fallback so the swap doesn't reflow.
Generate these per font pair with fontaine or the Fontaine CLI. */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22.4%;
line-gap-override: 0%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }
Preload one or two weights, never six. Subset to the character ranges you actually serve; a Latin-only Inter 400 woff2 lands around 15 to 20KB versus 100KB plus for the full file. If a font is decorative and only used in a heading, font-display: optional is honest: the browser skips it if it isn’t ready, and you get zero layout shift.
The tags you can defer and the ones you cannot
Tag managers are the worst offenders because the snippet is small but it injects an unbounded number of further scripts. GTM’s own snippet is async, but the container then loads whatever marketing added last Tuesday. Audit the container, not the snippet.
What works on real client sites:
- Analytics:
async, or load afterDOMContentLoaded. Plausible and Fathom are roughly 1KB; GA4’s gtag.js plus config is closer to 90KB of script execution. - Consent banners: these legitimately need to run early, which is why they’re often the single worst thing on the page. Pick one with a sub-10KB blocking core and load the rest after consent.
- A/B testing: anti-flicker snippets are blocking by design. If you run one, accept that FCP will suffer and keep the test window short.
- Partytown: moves third-party scripts to a web worker. It works, and the proxying of DOM access through synchronous XHR adds complexity and breaks some vendors. Only worth it when you have no control over the tag list.
Stop it regressing next quarter
Every optimisation described above gets undone by a stakeholder request within two releases unless something fails the build. Add Lighthouse CI to the pipeline with an actual budget:
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "stylesheet", "budget": 45 },
{ "resourceType": "script", "budget": 130 },
{ "resourceType": "third-party", "budget": 100 }
],
"timings": [
{ "metric": "first-contentful-paint", "budget": 1800 },
{ "metric": "largest-contentful-paint", "budget": 2500 }
]
}
]
npx @lhci/cli autorun --collect.staticDistDir=./dist \
--collect.numberOfRuns=3 --assert.budgetsFile=./budget.json
Budgets in KB, timings in ms. Run it on the built static output so there’s no server variance. Then check field data in Search Console or your CrUX dashboard monthly, because lab numbers and real users disagree more often than anyone admits, and INP problems show up there first.
One more structural point: a lot of render-blocking pain is inherited from the template you picked. A stylesheet that can’t be split, or a JS bundle where every component depends on one global init, limits what you can do afterwards. Modular CSS and per-component JS are worth checking before you buy, which is part of how to evaluate an HTML template properly. It’s why Canvas keeps component stylesheets in separate files rather than one monolith: you include the carousel CSS on pages with a carousel and nowhere else.
Frequently Asked Questions
Does inlining all my CSS make the site faster?
Only on cold-cache landing pages, and only up to a point. Inline bytes are never cached, so a visitor viewing four pages downloads the same CSS four times. Inline the first-viewport rules and keep the rest in a cacheable file loaded non-blocking.
Is the 14KB first-roundtrip rule still relevant?
Loosely. TCP slow start with an initial congestion window of 10 segments gives you roughly 14KB in the first flight, so keeping inlined critical CSS under that is a sensible target. With TLS 1.3, HTTP/3 and warm edge connections the cliff is much softer than it was, so don’t contort your build to hit the number exactly.
What’s the difference between defer and async in practice?
Both stop the script blocking the HTML parser. defer guarantees execution in document order after parsing completes, which is what you want for anything with dependencies. async executes the moment it downloads, in unpredictable order, which is only safe for standalone scripts like analytics.
Why does Lighthouse still flag my stylesheet after I added preload?
Because rel="preload" only fetches early, it doesn’t change blocking behaviour. You need rel="preload" as="style" with an onload that flips it to rel="stylesheet", or the simpler media="print" swap. A <link rel="stylesheet"> left in the head stays blocking regardless of what you preload.
Should I generate critical CSS per page or once for the whole site?
Per template, not per page. Group your URLs by layout (home, article, listing, contact), generate one critical block for each, and reuse it. Per-page extraction on a 500-page static site adds minutes to every build and the differences between pages of the same template are usually under 2KB.
Where to start tomorrow
Pick your highest-traffic page. Record the delta between HTML complete and First Contentful Paint in a throttled WebPageTest run. Then do three things in this order: self-host and preload the fonts, add defer to every script while moving inline init into one deferred file, and demote below-the-fold CSS with the media="print" swap. Re-measure.
If the gap is still above 500ms, extract critical CSS and accept the build step. If it isn’t, stop. The next hour is better spent on the 900KB hero image.


