MODRACXKENNETH D'SILVA

← Archive & Insights

Eliminating Render-Blocking CSS and JavaScript

A Shopify storefront scored 91 in Lighthouse and painted nothing for 3.4 seconds. The gap was a cross-origin @import and one synchronous app script.

By Kenneth D'SilvaReading Time: 30 min readCategory: Performance & Speed

1. The Waterfall That Made No Sense

A bathroom fittings retailer on Shopify sent me a WebPageTest run and a one-line brief: "Lighthouse says 91 on mobile but the site feels slow, can you look." The score really was 91. The filmstrip told a different story: nothing painted until 3.4 seconds on a Moto G4 over emulated 4G, and then the whole page arrived at once, fully styled.

The waterfall had eleven requests before first paint. Two of them were the culprits, and neither was the one the client expected. The theme's main stylesheet was 148KB and arrived at 1.1s. Fine. But the stylesheet contained an @import for an icon font CSS file hosted on a different domain, which the browser could not even see until it had parsed the first stylesheet, and which then needed a fresh DNS lookup, TLS handshake and round trip of its own. That second file was 4KB. It cost 780ms of pure serialised latency during which the browser had a fully downloaded main stylesheet it refused to use.

The other culprit was a review app that had injected a synchronous <script> tag into the theme's header six months earlier. Classic script, no attributes, sitting above the fold in the markup. The HTML parser stopped dead at it.

Fixing both took an afternoon. First paint went from 3.4s to 1.2s. The Lighthouse score went from 91 to 93, which tells you roughly everything you need to know about how much attention to pay to the number in the circle.

This article is about the blocking mechanism itself — what the browser is actually waiting for, why it waits, and how to take each blocker away without breaking the page in the process. The extraction workflow for critical CSS I've written about separately in the critical-CSS piece; this one is about the machinery underneath, which is the part people skip and then wonder why their fix didn't work.

2. What "Blocking" Actually Means

The word "blocking" gets used for two entirely different things and conflating them is the root of most bad advice on this topic.

Parser-blocking means the HTML parser stops consuming bytes. The document tree stops growing. Nothing further in the markup is discovered — not the images below, not the stylesheets further down the head, not the LCP element. The parser is a cursor moving through the byte stream, and a parser-blocking resource freezes the cursor.

Render-blocking means the parser keeps going, the DOM keeps growing, but the browser refuses to paint anything. It has a document tree; it just won't commit pixels to the screen yet.

These have very different costs. Parser-blocking is worse, because a frozen parser means the browser stops discovering work it could be doing in parallel. Render-blocking is expensive but at least the network stays busy.

To understand which resource does which, you need the actual pipeline, and it's shorter than most explanations make it.

The pipeline in five steps

Bytes arrive. The HTML parser tokenises them and builds the DOM incrementally — it does not wait for the whole document. Separately, every stylesheet the browser encounters gets fetched and parsed into the CSSOM, a tree of style rules. The DOM and the CSSOM are then combined into the render tree, which contains only the nodes that will actually be drawn, each with its computed style. Layout runs over the render tree to work out geometry. Paint fills in pixels.

The critical dependency is between step three and everything before it. The render tree needs both trees. If the CSSOM is incomplete, there is no render tree, and therefore no layout and no paint.

HTML bytes ──▶ tokens ──▶ DOM ─┐
                                ├──▶ render tree ──▶ layout ──▶ paint
CSS bytes  ──▶ tokens ──▶ CSSOM ┘

The render tree cannot be built until BOTH inputs exist.
A missing CSSOM node blocks paint. It does not block DOM construction.

3. Why CSS Blocks Rendering But Not Parsing

A stylesheet in the head is render-blocking by default. The browser will not paint until it has downloaded and parsed every stylesheet it has discovered that applies to the current media. This is deliberate and it is correct: if the browser painted with a partial CSSOM, you would see the page in one style and then watch it violently restyle itself. That was the flash of unstyled content that made the web look broken in 1998, and browsers have spent twenty-five years making sure you never see it again.

But CSS does not stop the HTML parser. The parser sails past a <link rel="stylesheet"> and keeps building DOM. This matters enormously, because it means the browser can continue discovering resources — images, scripts, other stylesheets — while a stylesheet is in flight.

There's one exception that people trip over. A stylesheet does effectively block a subsequent script, because a script may query computed styles, and the browser can't answer that question until the CSSOM is ready. So the sequence in the markup below serialises badly:

<link rel="stylesheet" href="/theme.css">
<script src="/widget.js"></script>
<!-- widget.js has DOWNLOADED but cannot EXECUTE until theme.css
     has downloaded AND parsed, because it might call
     getComputedStyle() and expect a truthful answer. -->

The script's download proceeds in parallel. Its execution waits. And because a classic script is parser-blocking, the parser waits on the execution, which waits on the CSS. One stylesheet has now stalled DOM construction, indirectly, via a script three lines below it. I have seen people move a stylesheet down the head to "get the script started earlier" and make things measurably worse for exactly this reason.

4. Why a Classic Script Blocks Both

A plain <script src="..."></script> is both parser-blocking and, as a consequence, render-blocking. The reason is document.write. Because a script may write into the byte stream at the point where it sits, the parser cannot know what comes next until the script has finished running. So it stops, downloads, executes, and only then resumes.

The cost is not just the execution. It's the download and the execution and — on a cold connection to a third-party host — the DNS lookup and TLS handshake in front of them. A synchronous script from a domain you've never contacted can easily cost 400 to 600ms on mobile before a single byte of its payload arrives. During all of that, your parser is frozen.

Inline scripts block too. An inline <script> has no download cost but its execution still halts the parser, and if it does something expensive — parsing a large JSON blob, running a polyfill detection loop, touching localStorage synchronously — it halts the parser for however long that takes. The theme-flash-prevention snippet most sites have in the head is an inline blocking script, and that's a legitimate use: you want it to run before paint. Just keep it to a few lines.

5. The Preload Scanner, and the Four Ways You Break It

Browsers do not actually sit idle while a synchronous script blocks the parser. Since roughly 2008 they have run a second, lightweight parser — the preload scanner, sometimes called the speculative parser — that races ahead through the raw markup looking for things it can start fetching. It doesn't build DOM and it doesn't execute anything. It just finds URLs in src and href attributes and gets them into the network queue early.

Four things defeat it, and I find at least one on most sites I audit.

JavaScript-injected assets

The scanner reads markup. It does not run JavaScript. So a resource that only exists after a script constructs it is invisible until that script runs:

// The preload scanner cannot see this. Nothing about hero.avif enters
// the network queue until this file has downloaded, parsed and executed.
const img = new Image();
img.src = '/media/hero.avif';
document.querySelector('.hero').appendChild(img);

Every lazy-loading library that swaps data-src into src does this. That's acceptable for images below the fold — that's the point of the library — and actively harmful for anything above it. Which brings us to the mistake I see most often.

Lazy loading the LCP image

Putting loading="lazy" or a data-src placeholder on your hero image is the single most common self-inflicted performance wound in ecommerce. On a product page, the LCP element is nearly always the main product image. Marking it lazy means the browser deliberately defers a resource it would otherwise have started immediately, and on a slow connection that costs 500ms to 1.5s of LCP directly.

<!-- Wrong: the LCP candidate, deferred on purpose -->
<img src="/products/kettle-1200.avif" loading="lazy" width="1200" height="1200" alt="Copper kettle">

<!-- Right: eager, high priority, and discoverable by the scanner -->
<img src="/products/kettle-1200.avif"
     fetchpriority="high"
     decoding="async"
     width="1200" height="1200"
     alt="Copper kettle">

fetchpriority="high" shipped in Chrome 101 in April 2022 and is now in Safari and Firefox as well. It matters because the preload scanner assigns images a low initial priority by default — it doesn't know which one is your LCP element, and most images on a page are not. The attribute tells it.

document.write

A script that writes markup into the stream invalidates everything the scanner speculated about. Chrome has intervened here since version 55 in 2016: it will refuse to execute document.write-injected external scripts on slow connections, and log a console warning. The intervention helps but it doesn't rescue the discovery order.

CSS @import

The scanner reads HTML, not CSS. An @import inside a stylesheet is only discovered when that stylesheet has been downloaded and parsed. That's the 780ms hole I found on the homeware site. Covered in more detail below, because it earns its own section.

6. defer, async, and type="module"

Three mechanisms, subtly different, and choosing wrongly produces bugs that only appear on slow connections — which is to say, in production, on real customers' phones, and never on your laptop.

AttributeBlocks parser?When it executesOrder guaranteed?
(none)YesImmediately on downloadYes — document order
asyncOnly while executingAs soon as it arrivesNo — arrival order
deferNoAfter parsing, before DOMContentLoadedYes — document order
type="module"NoLike defer, after parsingYes — document order
type="module" asyncOnly while executingAs soon as the graph resolvesNo

defer is what you want almost every time. The script downloads in parallel with parsing, executes after the document is parsed, in document order, before DOMContentLoaded fires. Your existing scripts that assume the DOM exists will keep working, and scripts that depend on each other will keep their sequence.

async is right for exactly one shape of script: self-contained, dependency-free, order-irrelevant. Analytics beacons. An error reporter. Anything that touches nothing else on the page. The moment two async scripts depend on each other, you have a race condition that fires maybe one time in fifty on a fast connection and constantly on a train.

Modules are deferred by default — that surprises people. You do not write <script type="module" defer>; the defer is redundant and ignored. What you can write is <script type="module" async>, which opts back into async semantics.

<!-- Deferred by default. The defer attribute here does nothing. -->
<script type="module" src="/js/app.js"></script>

<!-- The legacy fallback pattern. Module-aware browsers ignore the
     nomodule script; older ones ignore type="module" entirely. -->
<script nomodule defer src="/js/app.legacy.js"></script>

<!-- Independent, order-irrelevant: async is correct here -->
<script async src="https://errors.example.net/sdk.js"></script>

<!-- Flatten a deep module graph so the browser does not discover
     imports one round trip at a time -->
<link rel="modulepreload" href="/js/app.js">
<link rel="modulepreload" href="/js/cart-store.js">
<link rel="modulepreload" href="/js/api-client.js">

One trap with modules: they are fetched with CORS semantics even same-origin, and a module graph is resolved before execution. A deep import chain — module A imports B imports C imports D — costs you one round trip per level unless you bundle or preload the graph with modulepreload. I've seen an unbundled dev-style module graph add 900ms on 4G because the browser was discovering imports four levels deep, one hop at a time.

7. Splitting CSS With the media Attribute

The cleanest way to make a stylesheet non-render-blocking is to tell the truth about when it applies. A stylesheet whose media query does not match the current environment is downloaded at low priority and does not block rendering.

<!-- Blocks render on every device -->
<link rel="stylesheet" href="/css/all.css">

<!-- Only the matching one blocks. On a phone, wide.css downloads
     at low priority and never delays first paint. -->
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/wide.css"  media="(min-width: 900px)">
<link rel="stylesheet" href="/css/print.css" media="print">

The practical obstacle is that most CSS pipelines emit one file. Splitting by media query means either configuring your bundler to do it — PostCSS has plugins for this, and both Vite and webpack can be coaxed into media-based chunks — or accepting a modest duplication of shared rules across files. On a homeware client's Magento build I split a 210KB stylesheet into a 61KB base and a 149KB wide-viewport file. Mobile users stopped downloading two-thirds of the CSS on the render-blocking path. That's a bigger win than any amount of inlining, and it's boring, which is the highest compliment I can pay a fix.

8. The media="print" onload Pattern, and What It Actually Costs

This is the pattern everyone reaches for, including this very site's head, and it deserves an honest accounting rather than the enthusiastic write-ups it usually gets.

<link rel="preload" as="style" href="/css/theme.css">
<link rel="stylesheet" href="/css/theme.css"
      media="print" onload="this.media='all'; this.onload=null;">
<noscript><link rel="stylesheet" href="/css/theme.css"></noscript>

The mechanism: media="print" means the sheet doesn't apply to screen, so it doesn't block render. The onload handler flips it to all once it has downloaded, at which point it applies and the page restyles. The preload line above it raises the fetch priority back up, because a non-matching stylesheet is fetched at low priority and you usually don't want that for your main theme. The noscript line is the no-JavaScript fallback, without which a user with scripting disabled gets a permanently unstyled page.

Now the costs, which nobody mentions.

You have deliberately created a flash of unstyled content. That is what "don't block render" means. The browser paints, then the stylesheet arrives, then it repaints with styles. If your critical CSS is good, the visible difference is small. If it isn't, your customers see raw HTML for 400ms and then the site. Every complaint I've had about this pattern traces back to inadequate critical CSS, not to the pattern.

It can cause layout shift. The restyle happens after paint, so anything whose geometry changes when the full sheet applies moves. CLS is measured across the whole page lifetime, not just the first paint, so this counts against you. If your critical CSS sets the dimensions of everything above the fold, you're fine. If it only sets colours and fonts, you're not.

The double declaration costs you a priority argument. The preload and the stylesheet link refer to the same URL, and browsers have historically differed on how they reconcile the priorities. Chrome handles this well now — it recognises the pair and gives the fetch a sensible priority — but I've watched Safari fetch the resource at a priority I did not intend. Check the actual priority column in DevTools rather than assuming.

An inline event handler needs a CSP allowance. If you run a Content Security Policy with a strict script-src, that onload attribute is an inline script and will be blocked unless you allow unsafe-inline or hash it. Weakening your CSP to speed up your CSS is a bad trade. Attach the handler from an external script instead — mark the link with a data-async-style attribute, find it with querySelectorAll, and set link.media = 'all' on its load event. Same effect, no inline script, no CSP exception.

Would I use this pattern? Yes, on sites where I control the critical CSS and can verify the above-fold geometry is stable. On a site with a sprawling theme and no critical CSS discipline, I'd split by media query instead and accept a slower first paint over a page that visibly rebuilds itself.

9. Inline Critical CSS and the Point Where It Backfires

Inlining the styles needed for the first viewport into a <style> block in the head removes a network round trip from the critical path entirely. It is the single change with the biggest payoff on most sites, and it has a ceiling that people blow past.

The ceiling is caching. Inline CSS is part of the HTML document. It is re-downloaded on every single page view, and it cannot be cached independently. At 8KB of inline critical CSS on a page a customer visits six times in a session, you've shipped 48KB where an external file would have shipped 8KB once. Beyond roughly 14KB — the rough size of what fits in the initial congestion window on a fresh TCP connection, though the arithmetic is fuzzier with modern initcwnd and HTTP/2 — you're also pushing your document out of the first round trip, which is the exact thing inlining was supposed to avoid.

My working budget is 8 to 10KB of inline critical CSS, uncompressed. Above 14KB I stop and ask why the first viewport needs that much style. The answer is usually that the extraction tool grabbed rules for a mega-menu that is hidden behind a click, or every variant of a button component, or the entire icon font's worth of pseudo-element rules.

<head>
  <style>
    /* Critical: first-viewport geometry and typography only.
       Target 8-10KB. Everything else goes in the async sheet. */
    :root{--ink:#111;--paper:#fff}
    body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--paper)}
    .site-header{height:64px;display:flex;align-items:center;padding:0 16px}
    .hero{aspect-ratio:16/9;background:#eee}
    .hero img{width:100%;height:100%;object-fit:cover;display:block}
  </style>
  <link rel="stylesheet" href="/css/theme.css" media="print" onload="this.media='all';this.onload=null">
  <noscript><link rel="stylesheet" href="/css/theme.css"></noscript>
</head>

Note the aspect-ratio on the hero. Reserving the box in critical CSS is what stops the async stylesheet causing a shift when it lands. This is the part that separates a critical-CSS implementation that helps from one that trades FCP for CLS.

10. Fonts: The Blocker Nobody Puts in the Waterfall Diagram

Web fonts are render-blocking in a way that doesn't show up in Lighthouse's "eliminate render-blocking resources" audit at all, because technically they block text painting rather than the render tree. Practically, if the text is the LCP element — which it is on most category and content pages — a slow font is a slow LCP.

The discovery problem is the same one @import has. A @font-face rule lives in CSS. The browser doesn't know the font URL until it has parsed the stylesheet, and it doesn't fetch the font until it knows a rendered element actually uses that family. So the sequence is: HTML → CSS → CSSOM → layout discovers a text node in that family → font request. Four serial steps before the request even starts.

<!-- Break the chain: start the font fetch alongside the CSS.
     crossorigin is REQUIRED even same-origin - fonts are fetched
     in CORS mode, and without it you get a second, duplicate fetch. -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-var-subset.woff2" crossorigin>

Forgetting crossorigin on a font preload is the most common preload bug in existence. The preload is fetched in one mode, the font request in another, the cache entries don't match, and you download the font twice while congratulating yourself on the optimisation. Chrome warns about it in the console. Read your console.

FOIT, FOUT, and which one to choose

Without font-display, browsers apply a block period of around three seconds during which text in that family renders as nothing — invisible. That is FOIT, the flash of invisible text. Your LCP element does not exist during it.

font-display: swap reduces the block period to essentially zero: text paints immediately in the fallback and swaps to the web font when it arrives. That's FOUT, the flash of unstyled text. You get fast FCP and LCP at the cost of a visible reflow when the swap happens.

font-display: optional gives the font about 100ms to arrive; if it misses, the fallback is used for that entire page view and the font is quietly cached for next time. No swap, no shift. The trade is that first-time visitors on slow connections may never see your brand typeface.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-subset.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  /* Metric overrides make the fallback occupy near-identical space,
     which is what turns a visible reflow into an invisible one.
     Chrome 87+, Firefox 89+, Safari 17+. */
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

The metric override properties are what make swap safe. Without them the fallback and the web font have different x-heights and advance widths, the swap reflows the paragraph, and your CLS suffers. I'd pick swap plus overrides on a brand-conscious retail site, and optional where the typeface is decoration rather than identity.

11. @import Chains and Other Self-Inflicted Serialisation

Back to the 780ms hole from the opening. @import inside CSS is the worst construct in the language from a loading perspective and I would remove it from every production stylesheet without discussion.

/* theme.css — every one of these is a serial round trip.
   The browser cannot see reset.css until theme.css has arrived
   and been parsed. It cannot see typography.css until then either. */
@import url('reset.css');
@import url('typography.css');
@import url('https://cdn.example.net/icons/icons.css');

Three imports means at least one extra round trip, and a cross-origin one means DNS plus TLS on top. Worse, an @import inside an imported file chains further. I once traced a four-level chain on a Magento theme that had accumulated over three agency handovers; total serialised cost on 4G was 1.6 seconds of nothing happening.

The fix is to inline the imports at build time — every bundler does this, postcss-import being the standard tool — or to convert them into three sibling <link rel="stylesheet"> tags in the head, which the preload scanner sees all at once and fetches in parallel.

If the third file has to stay cross-origin, at minimum warm the connection ahead of time — the mechanics of that are in the preconnect and DNS-prefetch piece, and the short version is that a preconnect to a third-party CSS host saves you the handshake but not the discovery delay. Only removing the @import saves the discovery delay.

12. Third-Party Tags Are the Real Blocker on Most Storefronts

I'll say this plainly: on the majority of ecommerce sites I audit, the theme's own CSS and JS are not the problem. The problem is between four and fourteen third-party scripts that marketing added over two years, most of which nobody can name an owner for.

The base GTM snippet is async, which is fine. What it loads is not necessarily fine. A single mis-configured tag that does a synchronous document.write, or a chat widget that inserts a stylesheet, will block rendering, and it will start doing so the day someone publishes a container version — with no deploy, no code review, no changelog you can read.

Three things I do about it, in order of how much I like them.

Delete tags. Genuinely the best fix and the one nobody tries first. Export the container, list every tag, and ask each department to justify theirs. On a fashion client we removed nine of nineteen tags in a single meeting because nobody could say what they were for. Total blocking time fell by 610ms.

Delay the container until interaction or idle. This is what the head of this site does with gtag. Load the tag manager on the first pointer, key, touch or scroll event, or at browser idle after load, whichever comes first. Consent Mode defaults and the queued dataLayer calls survive because they're just array pushes.

// Deferred third-party loader: first interaction, or idle after load.
(function () {
  var loaded = false;
  function load() {
    if (loaded) return;
    loaded = true;
    var s = document.createElement('script');
    s.async = true;
    s.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX';
    document.head.appendChild(s);
  }
  ['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (e) {
    window.addEventListener(e, load, { once: true, passive: true });
  });
  // Backstop so bounced sessions still register.
  window.addEventListener('load', function () {
    if ('requestIdleCallback' in window) requestIdleCallback(load, { timeout: 3000 });
    else setTimeout(load, 2000);
  }, { once: true });
})();

The honest downside: you will under-count sessions where the user leaves before the idle callback fires. On sites where I've measured it the loss is between 1 and 3% of sessions, concentrated in very short bounces. Tell your analytics owner before you ship it, not after they notice.

Move it to a worker with Partytown. Partytown relocates third-party scripts into a web worker and proxies their DOM access back to the main thread synchronously via Atomics and a service worker. It genuinely takes the execution off the main thread. It is also the most operationally awkward thing on this list.

The caveats are real: it needs a service worker or the right COOP/COEP headers, the proxying adds latency to every DOM access the third-party script makes, and some scripts break outright because they assume synchronous patterns the proxy can't replicate. I've shipped it on two sites and abandoned it on a third where a personalisation vendor's SDK refused to work. Not a default.

13. blocking=render, and Deliberately Blocking on Purpose

Every technique so far removes blocking. Occasionally you want the opposite, and since Chrome 105 there's a standard way to ask for it.

<!-- Deliberately block the first paint until this async script has
     run. Useful for a script that must apply theme or personalisation
     before anything is visible. -->
<script async blocking="render" src="/js/theme-init.js"></script>

<!-- Also valid on a stylesheet or an inline style block -->
<link rel="expect" href="#app-shell" blocking="render">

The use case that justifies it: you have a personalisation or A/B testing script that rewrites above-the-fold content. Without blocking, the customer sees version A, then a flicker, then version B. The historical hack for this was an inline synchronous script with a timeout and a CSS class hiding the body — the "anti-flicker snippet" that every testing vendor ships and that costs you 200 to 4000ms of blank page depending on how the timeout is configured. blocking="render" is the honest version of the same intent, with the parser still running underneath.

My opinion: if you find yourself needing this, the better fix is server-side rendering of the variant. Client-side personalisation of above-the-fold content is a performance tax you pay on every page view forever, in exchange for a testing convenience. I have argued this and lost, repeatedly, so here is the attribute.

Early Hints, and whether it's ready

HTTP 103 Early Hints lets a server send preload and preconnect hints before the final response is ready. On a slow backend — a Magento page that takes 600ms of PHP before the first byte — that 600ms is dead network time you can spend fetching CSS.

# Nginx 1.25.1+ — send 103 with the critical resources, then the
# real response once the upstream has finished thinking.
http {
    early_hints on;

    server {
        location / {
            add_header Link "</css/theme.css>; rel=preload; as=style" always;
            add_header Link "</fonts/inter-var-subset.woff2>; rel=preload; as=font; crossorigin" always;
            proxy_pass http://app_upstream;
        }
    }
}

Practical readiness in 2026: Chrome supports it on navigation requests, Cloudflare will generate hints for you automatically, and Fastly supports it at the edge. Firefox and Safari do not, so treat it as an optimisation for roughly two-thirds of your traffic rather than a fix. It's also easy to get wrong — hinting a resource the final page doesn't use wastes bandwidth on exactly the connections that can't spare it. I turn it on where the CDN generates the hints from observed traffic, and I hand-write hints only for the two or three resources that are on every single page.

14. Which Metrics This Moves, and Which It Doesn't

This is where I spend most of my time correcting expectations, because "improve Core Web Vitals" gets translated into "remove render-blocking resources" and the two are not the same job.

First Contentful Paint: directly. FCP is the moment the first text or image is painted. Render-blocking resources are, by definition, the things preventing that. This is the metric render-blocking work owns.

Largest Contentful Paint: usually, indirectly. LCP cannot happen before FCP, so lowering the floor helps. But if your LCP element is a 400KB hero image, unblocking the CSS moves FCP a lot and LCP barely at all. I've delivered a 1.4s FCP improvement with a 90ms LCP improvement and had to explain why that was still worth doing.

Cumulative Layout Shift: no, and sometimes worse. CLS is about visual stability after paint. Removing render-blocking CSS means painting earlier with less style applied, which is a recipe for shift. Async stylesheets, swapped fonts, and lazily-styled components all move things. Every technique in this article needs a CLS check attached to it.

Interaction to Next Paint: no. INP measures responsiveness to input, which is a main-thread contention problem, not a loading problem. Deferring a script doesn't make it cheaper to execute — it just moves the expense later. A 400ms parse-and-execute of a bundle is 400ms of blocked main thread whether it runs at 800ms or at 2400ms. If it runs while the user is trying to tap something, you've converted an FCP problem into an INP problem. Splitting the bundle so less of it runs at all is the actual fix, which is the code-splitting conversation rather than this one.

If you want the metric-by-metric remediation framing rather than the mechanism, the Core Web Vitals article covers it, and the CI monitoring setup lives in the measurement piece.

15. Measuring It Without Fooling Yourself

Four tools, each good at something different, and one of them lies to you.

Lighthouse, and why its savings estimate is wrong

The "Eliminate render-blocking resources" audit lists your blocking resources and gives an estimated saving in milliseconds. That estimate is computed from a simulated network model, not from an actual measurement. It assumes you could remove the resource entirely at zero cost and it does not model what happens next — the reflow, the second paint, the shift.

I have twice implemented exactly what the audit suggested and produced a real-world regression, because the audit does not know that the stylesheet it wants you to defer contains the layout for your hero. Treat the list as a list. Ignore the number.

# Applied (real) throttling rather than the default simulation.
# Slower to run, far closer to what a device actually experiences.
lighthouse https://shop.example.com/products/copper-kettle \
  --throttling-method=devtools \
  --preset=desktop=false \
  --only-categories=performance \
  --output=json --output-path=./lh.json

# Pull just the blocking resources out of the report
jq '.audits["render-blocking-resources"].details.items[]
    | {url: .url, wasted: .wastedMs, bytes: .totalBytes}' ./lh.json

WebPageTest waterfalls

Still the best tool for this problem, because the waterfall shows the shape of the dependency. You're looking for a staircase: a request that starts only after another finishes, with a gap. That gap is your serialised discovery cost, and no synthetic score will tell you about it.

Run it on a real device profile — a Moto G Power on 4G is my default because it's roughly the median Android device your customers actually hold — and read the filmstrip alongside the waterfall. The frame where content appears is your real FCP; the frame where it stops changing is where your async CSS landed.

DevTools Performance panel

Record a load with CPU throttled to 4x or 6x. Look at the Main track for the long "Parse HTML" task with a script execution nested inside it — that's your parser-blocking script, visually. Look for gaps between the end of a resource download and the first Paint event. The "Frames" track shows you exactly when the first pixels landed.

PerformanceObserver in the field

Synthetic tests measure one device on one connection. Field data measures everyone. For render-blocking work, FCP is the number to collect, segmented by device class:

// Field FCP collection. Send to your own endpoint, not a third party
// that you then have to defer, which would be circular.
new PerformanceObserver(function (list) {
  for (const entry of list.getEntriesByName('first-contentful-paint')) {
    const nav = performance.getEntriesByType('navigation')[0];
    navigator.sendBeacon('/rum/fcp', JSON.stringify({
      fcp: Math.round(entry.startTime),
      ttfb: Math.round(nav ? nav.responseStart : 0),
      // FCP minus TTFB isolates the render-blocking cost from
      // server slowness, which is the number you actually changed.
      blocked: Math.round(entry.startTime - (nav ? nav.responseStart : 0)),
      conn: (navigator.connection || {}).effectiveType || 'unknown',
      path: location.pathname
    }));
  }
}).observe({ type: 'paint', buffered: true });

The buffered: true flag is essential — paint entries fire before your observer registers, and without it you'll collect nothing and spend a morning confused. The derived blocked figure is the one I report to clients, because it separates "your server is slow" from "your head is badly organised", and those go to different teams.

16. Shopify: theme.liquid and the Apps You Didn't Install

Shopify gives you less control than you want and more than most people use.

What you control: everything in theme.liquid, the order of your own tags, and your asset pipeline. What you don't: content_for_header, which Shopify renders and which includes the analytics bundle and script tags for installed apps, and the checkout, which on non-Plus plans is off-limits.

A sane theme.liquid head, in this order:

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">

  {%- comment -%} Warm the CDN connection before anything needs it {%- endcomment -%}
  <link rel="preconnect" href="https://cdn.shopify.com" crossorigin>

  {%- comment -%} Inline critical CSS, rendered from a snippet {%- endcomment -%}
  <style>{% render 'critical-css' %}</style>

  {%- comment -%} Font first: it is discovered latest and needed earliest {%- endcomment -%}
  <link rel="preload" as="font" type="font/woff2"
        href="{{ 'inter-var-subset.woff2' | asset_url }}" crossorigin>

  {%- comment -%} Main theme sheet, off the critical path {%- endcomment -%}
  <link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"
        media="print" onload="this.media='all';this.onload=null">
  <noscript><link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"></noscript>

  <script src="{{ 'theme.js' | asset_url }}" defer></script>

  {%- comment -%} Shopify's own output goes LAST. Anything it injects
      then arrives after your critical resources are already in flight. {%- endcomment -%}
  {{ content_for_header }}
</head>

Moving content_for_header to the bottom of the head is the highest-value single change on most Shopify themes, and it is entirely supported — Shopify's own documentation no longer insists it goes first. On the homeware site from the opening, that move alone was worth 340ms of FCP because it stopped four app scripts from being discovered ahead of the theme stylesheet.

The app problem is harder. Apps inject script tags through the ScriptTag API or, increasingly, through theme app extensions that append blocks to your templates. You can audit them:

# List every third-party host a product page pulls from.
# Anything you cannot name an owner for is a candidate for removal.
curl -s https://shop.example.com/products/copper-kettle \
  | grep -oE 'src="https?://[^"]+"' \
  | sed -E 's|src="https?://([^/"]+).*|\1|' \
  | sort | uniq -c | sort -rn

Uninstalling an app does not always remove its script tag. I've found tags from apps uninstalled eighteen months earlier still firing, because the ScriptTag was never cleaned up and nobody looked. Check theme.liquid and the theme's snippets/ directory for orphaned includes after every app removal.

17. Magento 2: Bundling, RequireJS, and the defer That Breaks Everything

Magento 2's frontend is a special case because RequireJS is doing dependency resolution at runtime, in the browser, on every page load.

The default configuration is bad. JavaScript bundling in Magento's native form concatenates essentially every module into a handful of enormous files — I've measured 3.2MB of bundled JS on a stock 2.4.6 install with a couple of extensions. It is loaded before rendering completes and it is mostly code the page will never call.

# The settings that matter. Merging is fine; native bundling is not.
bin/magento config:set dev/js/merge_files 1
bin/magento config:set dev/js/enable_js_bundling 0
bin/magento config:set dev/js/minify_files 1
bin/magento config:set dev/css/merge_css_files 1
bin/magento config:set dev/css/minify_files 1

bin/magento setup:static-content:deploy en_GB -j $(nproc)
bin/magento cache:flush

Turning native bundling off and using Baler or a custom webpack-based bundle is what I'd do on any project with the budget. Baler analyses which modules a page type actually needs and emits per-page-type bundles. It's unmaintained enough to be a slight risk and still better than the native option.

Now the trap that gives this section its title. Someone reads a performance article, adds defer to the RequireJS script tag, and the site breaks in ways that only appear on some pages.

<!-- app/design/frontend/Vendor/theme/Magento_Theme/layout/default_head_blocks.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <head>
        <!-- This is safe: your own theme code, no inline dependents -->
        <script src="js/storefront-enhancements.js" defer="true"/>

        <!-- Do NOT do this to requirejs/require.js. Inline x-magento-init
             blocks throughout the page call require() synchronously and
             will throw ReferenceError if require is not yet defined. -->
    </head>
</page>

Magento sprinkles <script type="text/x-magento-init"> and inline require([...]) calls throughout the page body — in templates, in widgets, in third-party module output. Those inline blocks execute in document order and expect require to be a defined global. Defer the loader and every one of them throws. The failure is partial and confusing: the page renders, the header works, and the add-to-cart button silently does nothing on product pages with a particular widget.

What does work on Magento, in order of payoff:

Inline critical CSS per page type — one for CMS, one for category, one for product — injected via a layout XML block, with the merged stylesheet moved to the async pattern. Magento's merged CSS is commonly 400KB+ and it is the single largest render-blocking resource on the page.

Removing unused modules from the layout entirely, rather than deferring them. bin/magento module:disable on the modules a B2B-only or B2C-only store doesn't use is free performance. Magento_Swatches, Magento_ProductVideo, Magento_Wishlist and the review modules are frequent candidates.

And moving the whole storefront off Luma. I've had this argument enough times to state it flatly: if render-blocking JS is your top problem on Magento and the budget exists, a Hyvä theme removes RequireJS and jQuery entirely and takes the JS payload from megabytes to about 30KB. Every optimisation above is working around an architecture. Replacing the architecture works better.

18. A Worked Example, Including the Part That Went Wrong

A specialist kitchenware retailer, Shopify Plus, roughly 12,000 sessions a day, 68% mobile. They came to me because their agency's performance sprint had finished and the numbers hadn't moved.

The starting position. Field data from CrUX over 28 days: FCP p75 of 3.1s, LCP p75 of 4.4s, CLS p75 of 0.04, INP p75 of 210ms. Lab on a Moto G Power / 4G profile: FCP 3.4s, LCP 4.9s. Lighthouse mobile performance 58.

What was blocking. The waterfall showed six render-blocking resources. A 212KB theme stylesheet. A Google Fonts stylesheet, cross-origin, which then referenced two font files the browser couldn't see until it had parsed the CSS. A currency-converter app's synchronous script. A cookie-consent script, synchronous by vendor recommendation. A 41KB "custom.css" containing four years of client tweaks. And an @import for an icon set inside the theme stylesheet.

Week one. Self-hosted and subset the fonts, preloaded the two used weights with crossorigin, added metric overrides with font-display: swap, inlined the @import at build time, and moved content_for_header to the bottom of the head. Lab FCP went from 3.4s to 2.2s, nothing regressed.

Week two, and here's the mistake. I extracted critical CSS with a headless-Chrome tool, inlined it, and moved the 212KB theme sheet plus custom.css to the media="print" pattern. Lab FCP dropped to 1.3s. I was pleased with myself for four hours.

Then the field data started arriving. FCP p75 improved to 1.9s, as expected. LCP p75 got worse — 4.4s to 4.7s. And CLS p75 went from 0.04 to 0.19, which is a fail.

Two separate causes, and I'd made both errors.

The CLS regression was the product grid. The extraction tool had run against the homepage at a 360×640 viewport and captured the hero and the header, but the product grid below started at about 620px and only its first row was in frame. So the grid's grid-template-columns and its card aspect-ratio weren't in the critical CSS. The page painted with the grid as a single stacked column of unsized cards, then the async stylesheet landed and it snapped into three columns with fixed-ratio images. A large, visible shift, on the most-viewed template.

The LCP regression was subtler and more embarrassing. The hero image was set as a CSS background-image on a section — which meant the browser couldn't discover it until the CSSOM was built. Previously that CSSOM came from the render-blocking stylesheet, which had high priority and arrived early. Now the stylesheet was media="print", fetched at low priority. The background image was discovered later than before. I had made the LCP element's discovery worse by de-prioritising the thing that revealed it.

Week three, the corrections. Re-ran critical extraction across four viewport heights (640, 800, 900, 1200) and three templates, unioned the results, and hand-added the grid geometry rules. Inline critical CSS went from 6.1KB to 9.4KB, which is inside my budget. Added the preload as="style" line ahead of the print-media link to restore the priority. And converted the hero from a CSS background to a real <img> with fetchpriority="high", which is what it should always have been — it made the LCP element visible to the preload scanner for the first time.

Week four. Deferred the cookie-consent script against the vendor's advice, after checking with their support that the queued-consent API worked. Moved the currency converter to load on interaction. Both were straightforward; the argument about the consent vendor took longer than the code.

Metric (CrUX p75)BeforeAfter week 2After week 4
FCP3.1s1.9s1.5s
LCP4.4s4.7s2.3s
CLS0.040.190.03
INP210ms205ms190ms
Lighthouse mobile587988

What it cost. Around nine days of engineering across five weeks. Conversion rate on mobile rose 4.1% over the following six weeks against the prior period, which the client's analyst was appropriately cautious about attributing entirely to this. Revenue per mobile session rose 3.2%.

What I'd do differently. Ship the async-CSS change to 10% of traffic first and watch field CLS for 72 hours before rolling it out. I had lab numbers that looked excellent and a field regression that took nine days to surface because CrUX is a 28-day rolling window. A field-measured canary would have caught the grid shift on day one instead of day nine. I now run the PerformanceObserver snippet from the measurement section on every project before I touch anything, precisely so I have a same-day signal rather than a month-lagged one.

19. Questions I Get Asked

"Should I just put all my scripts at the bottom of the body?" That advice is from 2010 and it's now mostly obsolete. defer gives you the same execution timing with better download timing, because the preload scanner finds a deferred script in the head immediately and starts fetching it while the parser works. Bottom-of-body means the script isn't discovered until the parser gets there. Head plus defer beats bottom-of-body in nearly every case.

"Lighthouse says my stylesheet blocks for 1,200ms. Will deferring it save 1,200ms?" No. That number is a simulation of removing the resource entirely, and it doesn't model the second paint, the reflow, or the shift. Expect somewhere between a third and two-thirds of it in practice, and check LCP and CLS before you claim anything.

"Can I use rel=preload to make a stylesheet non-blocking?" Preload on its own doesn't apply the stylesheet at all — it only fetches it. You still need the <link rel="stylesheet"> to apply it, which is why the print-media pattern uses both lines. Preload changes priority, not semantics. Over-preloading also actively hurts: every high-priority preload competes with the resources that genuinely need bandwidth first, and I've seen a page with fourteen preloads in the head where removing eight of them improved LCP by 300ms. More on the trade-offs in the resource hints piece.

"My site is behind Cloudflare and they offer automatic optimisation. Should I use it?" Rocket Loader in particular rewrites your scripts to load asynchronously, which will break anything with ordering dependencies, and it does so invisibly. I've spent whole days debugging a "random" JavaScript failure that turned out to be Rocket Loader. Their newer Speed Brain and early-hints generation are fine and I do use those. Automatic script rewriting, no.

20. What I'd Do First

Given a storefront and a week, this is the order, and the order is the point — several of these change what the later ones should be.

One. Identify the LCP element on your three highest-traffic templates. Not the homepage — the product page, the category page, and whichever landing template gets paid traffic. Write down what the element is and how the browser discovers it. Everything after this depends on knowing that.

Two. Put field FCP collection in place, segmented by connection type and template. You need a same-day signal, because CrUX will not tell you about a regression for a fortnight. Ten lines of PerformanceObserver, beaconed to your own endpoint.

Three. Run a WebPageTest on a real mid-range Android profile and read the waterfall for staircases. Every gap between a completed download and a dependent request starting is serialised discovery cost, and those are the cheapest wins on the page.

Four. Fix the discovery problems before touching anything else. Remove @import. Preload fonts with crossorigin. Convert any CSS-background LCP image to a real <img> with fetchpriority="high". Take loading="lazy" off anything above the fold. Low-risk, invisible to the design, and on the kitchenware project they were most of the first second.

Five. Audit the third parties and delete the ones nobody owns. Then defer the survivors to interaction-or-idle. This is usually the largest single number on the page and it requires no engineering skill at all, only the willingness to have four uncomfortable conversations.

Six. Add defer to your own scripts, one at a time, testing each. Not async unless the script is genuinely independent. Not on RequireJS if you're on Magento.

Seven. Split your CSS by media query if your build allows it. It's the version of this work with no downside and no fallback story, and it's frequently worth more than inlining.

Eight, and last. Critical CSS extraction and the async pattern. It's the biggest lab win and the one most likely to hurt you, so it goes after you have field measurement, after you know your LCP element, and after the free wins are banked. Extract across multiple viewport heights and multiple templates. Hand-check the grid and card geometry. Ship it to a fraction of traffic and watch CLS for three days.

The reason the order matters more than the individual techniques: the thing that made my kitchenware project go wrong wasn't the async CSS pattern, which is fine. It was doing the async CSS pattern before I understood how the LCP element was discovered. The blocking mechanism is simple. What's blocking on your page, and what happens when you remove it, is the part that takes a week to find out.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: