Font Loading Without the Render-Blocking Penalty

Adding web fonts to a project usually means dropping a stylesheet <link> into the <head> and calling it done. That approach works, but Lighthouse will flag those font stylesheets as render-blocking resources that delay how quickly content appears on screen.

Stylesheets in the <head> have been flagged by Lighthouse as render-blocking resources and they add a one-second delay to render? Not great.

The fix is to treat fonts as non-critical assets: load them after the page has rendered. The trade-off is the flash of unstyled text (FOUT), where users briefly see fallback fonts before the web font arrives. With a little vanilla HTML, CSS, and JavaScript, both problems can be solved without adding any framework-specific dependencies.

Why Font Stylesheets Block Rendering

Browsers build a render tree from the DOM and CSSOM. Until the HTML document and every linked stylesheet are loaded and parsed, the browser can't paint anything. A font stylesheet fetched from a CDN is part of that critical path, and so is the font file it references via @font-face. The result is a blank screen while the browser waits on those resources.

@font-face {
  font-family: 'Merriweather';
  src: local('Merriweather'), url(https://fonts.gstatic.com/...) format('woff2');
}
Critical render path delay when loading font stylesheet and font file 
(Credit: web.dev under Creative Commons Attribution 4.0 License)

Content is what users actually came for, so it should render as early as possible. Deferring fonts — and other non-critical assets — lets content display immediately. Users on slow connections won't be staring at an empty viewport, and missing typography is far less disruptive than a page that appears broken.

Optimized websites render content with critical CSS as soon as possible with non-critical resources deferred. A font switch occurs between 0.5s and 1.0s on the second timeline, indicating the time when presentational styles start rendering.

An Optimal Font Loading Sequence

Harry Roberts documented a solid strategy for Google Fonts that works in four steps:

  • Preconnect to the font file origin.
  • Preload the font stylesheet asynchronously with low priority.
  • Asynchronously load the font stylesheet and font file after content has rendered.
  • Provide a fallback font for users with JavaScript disabled.
<!-- https://fonts.gstatic.com is the font file origin -->
<!-- It may not have the same origin as the CSS file (https://fonts.googleapis.com) -->
<link rel="preconnect"
      href="https://fonts.gstatic.com"
      crossorigin />

<!-- We use the full link to the CSS file in the rest of the tags -->
<link rel="preload"
      as="style"
      href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap" />

<link rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap"
      media="print" onload="this.media='all'" />

<noscript>
  <link rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap" />
</noscript>

Setting media="print" on the stylesheet link is the key trick. Browsers treat print stylesheets as low priority and exclude them from the critical render path. Once loaded, the onload handler switches the media attribute to all, applying the font to screens, print, and speech output.

Lighthouse is happy with this approach!

Self-hosting fonts can also reduce render-blocking, but it isn't always practical. CDNs are sometimes the right choice for serving static assets, so knowing how to load fonts asynchronously from a CDN is worth having.

Managing FOUT

Deferring font files means users will briefly see text rendered in a fallback font, and the swap can cause layout shifts. The goal is to make that transition as invisible as possible.

Three steps help smooth the swap:

  • Pick a fallback system font whose metrics closely match the web font.
  • Adjust font-size, line-height, and letter-spacing on the fallback font so it occupies similar space.
  • Remove the fallback-specific styles and apply the web font styles once the font file is confirmed to be loaded.

Tools like Font Style Matcher can help find a good system font match for a given web font.

Detecting When a Font Is Ready

Libraries like Typekit's web font loader have not seen meaningful updates in years. More importantly, they add unnecessary JavaScript when the native CSS Font Loading API already provides what's needed. It has roughly 95% browser support, and a small fallback path covers the rest.

The API's check() function returns whether a specified font is available. One important caveat: the API only tracks fonts applied to actual DOM elements. At least one element on the page must have the web font declared in its styles, even if it's just a single hidden character like &nbsp;. The font name passed to check() must also match the font-family name used in the CSS exactly.

document.fonts.check("12px 'Merriweather'");

With less than 30 lines of JavaScript, a listener can poll check() at a set interval, then swap in the web font styles once the font loads. The same code should handle two edge cases: errors in the API or font loading, and users browsing with JavaScript disabled.

How the Parts Fit Together

The HTML in the <head> handles preconnect, preload, and the async stylesheet fallback. A hardcoded .no-js class on the <body> gets removed when the document finishes loading, which controls whether web font styles apply for users without JavaScript.

<body class="no-js">
  <!-- ... Website content ... -->
  <div aria-visibility="hidden" class="hidden" style="font-family: '[web-font-name]'">
      /* There is a non-breaking space here */
  </div>
  <script> 
    document.getElementsByTagName("body")[0].classList.remove("no-js");
  </script>
</body>

A hidden <div> containing a single &nbsp; character with an inline font-family: 'Merriweather' style gives the Font Loading API something to track. It's hidden accessibly — display: none; won't work here because the API needs the element to exist in the rendered tree.

CSS uses hardcoded classes for the no-JS state and conditional classes applied by JavaScript for the loading states.

body:not(.wf-merriweather--loaded):not(.no-js) {
  font-family: [fallback-system-font];
  /* Fallback font styles */
}


.wf-merriweather--loaded,
.no-js {
  font-family: "[web-font-name]";
  /* Webfont styles */
}


/* Accessible hiding */
.hidden {
  position: absolute; 
  overflow: hidden; 
  clip: rect(0 0 0 0); 
  height: 1px;
  width: 1px; 
  margin: -1px;
  padding: 0;
  border: 0; 
}

JavaScript runs the check at a regular interval. The listener function stays simple for efficiency, and a try-catch block ensures that if anything goes wrong, the font still applies. On success, the interval is cleared and the appropriate classes are added to trigger the web font styles.

var interval = null;


function fontLoadListener() {
  var hasLoaded = false;


  try {
    hasLoaded = document.fonts.check('12px "[web-font-name]"')
  } catch(error) {
    console.info("CSS font loading API error", error);
    fontLoadedSuccess();
    return;
  }
  
  if(hasLoaded) {
    fontLoadedSuccess();
  }
}


function fontLoadedSuccess() {
  if(interval) {
    clearInterval(interval);
  }
  /* Apply class names */
}


interval = setInterval(fontLoadListener, 500);

A Gatsby Implementation

Gatsby's server-side rendering and React startup require a slightly different approach than a vanilla setup. A local Gatsby plugin keeps all font loader code in one place, split across three main files:

  • gatsby-config.js contains plugin configuration: external and local font definitions (font name and CSS file URL) plus preconnect URLs.
  • gatsby-ssr.js generates preload and preconnect tags into the HTML <head> via Gatsby's setHeadComponents API, and injects the hidden tracking element using setPostBodyComponents.
  • gatsby-browser.js runs after React hydrates, so font stylesheet links can be injected asynchronously via react-helmet. It also starts the font loading listener to handle FOUT.

For teams that want a ready-made solution, the gatsby-omni-font-loader plugin wraps this exact logic into a drop-in package.

Prioritize Content Over Typography

Users should see page content as quickly as possible, which means minimizing the critical render path to just HTML and essential CSS. Web fonts don't need to be part of that initial paint. Loading them after rendering eliminates the delay, but introduces FOUT. A small font loading listener bridges that gap, swapping in the web font at the moment it becomes available and keeping layout shifts to a minimum.