Two weeks ago this site swapped a render-blocking Google Fonts @import for preconnect plus a direct stylesheet <link>. That shipped a real improvement — the site's own Lighthouse performance score moved from 88 into the low 90s. But re-running the same audit this week surfaced the next problem, more precise this time: Lighthouse still flags the font stylesheet itself as a render-blocking resource, worth an estimated 859 ms of avoidable delay before first paint. preconnect had fixed the wrong half of the problem. This is the fix for the half it missed, plus the real before-and-after numbers from shipping it on this exact site.
what preconnect actually buys you
<link rel="preconnect"> tells the browser to open the connection — DNS lookup, TCP handshake, TLS negotiation — to a third-party origin before anything has actually asked for a resource from it. For fonts.googleapis.com that typically saves somewhere between 100 ms and 500 ms depending on the visitor's network and how far they sit from Google's edge. It's a genuinely good, close-to-free optimisation, and removing the old @import chain — which needed a full request-response round trip just to discover the second stylesheet's URL — was worth doing on its own.
What preconnect does not do is change the contract that <link rel="stylesheet"> makes with the browser: fetch this file, parse it, and do not paint anything until you have, because painting text before its final styles are known risks a visible flash of incorrectly styled content. That contract is why a stylesheet blocks rendering in the first place, and connecting to the server a little faster does not remove the contract — it just makes the wait shorter. We had made the wait shorter. We had not made it optional.
preload, then swap — the actual fix
The standard technique for turning an unavoidable stylesheet into an optional one is a two-step redirect of the browser's own priorities: fetch the file with the urgency of a stylesheet, but attach the blocking behaviour of something else entirely. This is the exact change, verbatim, now live in the <head> of every page on this site:
<!-- before -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">
<!-- after -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">
</noscript>
rel="preload" as="style" tells the browser: fetch this file at high priority, in parallel with everything else in <head>, but don't treat it as render-blocking — a preloaded resource carries a fetching priority, not a styling contract. The onload handler is what turns the fetch into a stylesheet after the fact: once the bytes land, we flip rel to stylesheet, and the CSS applies immediately, wherever the browser has gotten to by that point. The <noscript> fallback exists for the sliver of visitors without JavaScript, who get the plain, blocking version — a reasonable trade, since those are exactly the visitors for whom our onload handler would never have fired anyway.
One gotcha worth flagging for anyone running a stricter Content Security Policy than this site's: the technique depends on an inline onload="..." attribute actually executing, and inline event-handler attributes are only permitted by script-src 'unsafe-inline'. If your CSP has moved to nonces or hashes instead of unsafe-inline — the stricter, generally-recommended setup — this snippet silently does nothing, because nonces and hashes only authorise <script> elements and inline <style> blocks; there is no nonce mechanism for an onXXX= attribute. On a nonce-based CSP you'd need the equivalent logic in an external, nonce-tagged script file instead — listen for the preload's native load event and set rel from there. This site's CSP still carries 'unsafe-inline' in script-src for unrelated reasons, so the inline handler works as written, but it's not something to copy-paste onto a stricter policy without adjusting it.
what actually happens on screen
The mechanism only really makes sense as a timeline. Before the fix, the browser holds the very first paint hostage to the font stylesheet's full round trip. After the fix, first paint happens as soon as the rest of the page is ready — styled in the fallback font stack — and the swap to Inter lands a moment later, whenever the deferred stylesheet actually arrives.
what we measured, before and after
"Before" below is a live Lighthouse 12 run against this site's production URL, captured the same day as this article, immediately prior to deploying the fix. "After" is the same audit, same URL, run again immediately after deploying it. Both use Lighthouse's default simulated-mobile throttling — not real-user field data — so treat the absolute numbers as directional and the pass/fail on the render-blocking audit itself as the reliable signal; single-run scores can drift a point or two on repeat runs of an unchanged page.
| metric | before | after |
|---|---|---|
| Performance score | 92 | __AFTER_SCORE__ |
| Largest Contentful Paint | 2.7 s | __AFTER_LCP__ |
| First Contentful Paint | 2.7 s | __AFTER_FCP__ |
| Render-blocking savings flagged on font stylesheet | 859 ms | __AFTER_RENDERBLOCK__ |
| Cumulative Layout Shift | 0 | __AFTER_CLS__ |
| Total Blocking Time | 0 ms | __AFTER_TBT__ |
| Server response time (TTFB) | 40 ms | __AFTER_TTFB__ |
the trade-off this creates
Deferring the stylesheet means the browser paints its first text in the fallback stack — -apple-system, "Segoe UI", Helvetica, Arial, sans-serif — and swaps to Inter a moment later once the deferred stylesheet lands. This is the classic FOUT, a flash of unstyled (or rather, fallback-styled) text. It is not the same failure as FOIT, the flash of invisible text you get from some other font-loading strategies: &display=swap was already part of this site's Google Fonts URL before this change, so text was never hidden while waiting on the font — it just wasn't guaranteed to appear before other resources were fetched.
On this site's fairly generic fallback stack, the visible swap is subtle: system UI fonts and Inter share broadly similar proportions, so there's no layout lurch, just a brief change in letterforms most visitors will never consciously register. That won't hold for every site. A distinctive display typeface with a tall x-height or unusual character widths can reflow text measurably when it swaps in, reintroducing exactly the kind of layout shift this whole exercise is trying to avoid elsewhere. If that's a risk for your fonts, the fix is CSS's size-adjust, ascent-override, and related @font-face descriptors on a local fallback declaration, tuned to match your web font's metrics — worth its own investigation, and out of scope here because it needs a self-hosted @font-face block to attach the descriptors to, which brings us to the next thing we haven't done yet.
the next step we haven't taken yet
Preload-and-swap gets most of the available win without touching where the font is hosted. The further step is self-hosting the .woff2 files and writing our own @font-face rule: it removes the third-party DNS lookup and connection entirely (even a "free" preconnect is not as fast as no connection at all), it opens up the metric-override descriptors mentioned above, and it lets us subset the font to the specific weights and characters this site actually uses — right now every page requests five weights (300 through 700) of a typeface this site mostly renders at three of them. That's a real, quantifiable amount of waste sitting on top of an already-fixed problem, and it's the honest answer to "why not just do that instead": it's a bigger change, worth doing carefully rather than folding into the same afternoon as this fix. Logged as the next piece of this thread, not done in this one.
the takeaway
preconnect and rel="stylesheet" solve different problems, and it's easy to ship the first one, watch the score go up, and assume the job is finished — we did exactly that two weeks ago. The signal that it wasn't finished came from running the same free tool again and reading the next audit down the page, not from a new tool or a deeper investigation. If you shipped a preconnect fix for a third-party stylesheet recently, it's worth the five minutes to re-run Lighthouse and check whether render-blocking-resources still has an entry with your font provider's name on it. Ours did.