Font Loading Strategies Without Layout Shift
AI generated
60fps
ms
Performance · Rendering · Fonts · Magento 2
Font Loading Strategies Without Layout Shift
FOIT, FOUT, FOFT, and getting font-display right

Web fonts are one of the most common causes of invisible text and jarring layout shifts. This article explains the rendering mechanics behind FOIT, FOUT, and FOFT, shows how font-display, preloading, variable fonts, and size-adjust work together, and makes font loading in Magento and Hyvä stores technically clean and measurably stable.

14 min. read FOIT · FOUT · FOFT · font-display Variable Fonts · size-adjust · Preload

1. Why font loading is a rendering decision

Web fonts are not shipped automatically with the HTML but loaded as a separate resource, and in the meantime the browser has to decide what to do with text whose target typeface isn't yet available. That decision is not a minor detail; it is one of the few places where the browser's rendering behavior is directly configurable. If you don't know the options, you leave the decision to whatever default the browser engine happens to ship, and those defaults differ across engines.

This is especially relevant for Magento and Hyvä stores, where product pages often load several font weights at once: regular for body copy, bold for prices and headlines, sometimes a separate icon font. Each of these files goes through the same lifecycle of request, download, and render permission. Without deliberate control via font-display and preloading, this adds up to noticeable delays in text visibility and unexpected layout jumps once the real font replaces the fallback.

2. FOIT, FOUT, and FOFT in detail

FOIT (Flash of Invisible Text) describes the behavior where the browser keeps text invisible until the web font file has fully loaded. This was Chrome's default for a long time and leads to an empty viewport on slow connections, even though the text is technically already in the DOM. FOUT (Flash of Unstyled Text) is the opposite: the browser renders immediately with a fallback font and swaps it once the target font is available. Text is always readable, but a visible jump can occur if the fallback and target fonts have different advance widths.

FOFT (Flash of Faux Text) is a third, less common strategy: a reduced version of the target font is loaded first, often only the regular weight, while bold or italic are synthesized client-side. Once the full weights arrive, the browser replaces the synthesized variants with the real ones. FOFT reduces the initial download size, but at the cost of potentially two visible swaps instead of one. In practice, swap combined with well-matched fallback metrics is the most robust compromise between visibility and visual stability for most Magento stores.

3. font-display: auto, block, swap, fallback, optional

The CSS font-display property in an @font-face declaration controls exactly this behavior declaratively, without JavaScript. It defines two time windows: the block period, during which text stays invisible if the font hasn't loaded yet, and the swap period, during which the browser renders with the fallback font and swaps it in once ready. After both windows expire, the browser permanently uses either the target font or the fallback font, depending on the value.

auto leaves the decision to the browser and is therefore unpredictable. block forces a short block period (typically 3 seconds) followed by an unlimited swap period, which caps FOIT to a short duration. swap sets the block period to effectively zero and renders immediately with fallback, producing FOUT, and is the right default for most content pages. fallback allows only a very short block window (about 100ms) and a short swap window (about 3s), after which the fallback font stays fixed permanently if the target font arrives too late. optional goes even further: no block window, a minimal swap window, and the browser is allowed to abort the download entirely under a slow connection and permanently use the fallback font, ideal for load time under network pressure with zero CLS contribution.


/* font-display declaratively controls the block and swap time windows */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap; /* immediate fallback render, swap when ready */
}

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-bold.woff2") format("woff2");
  font-weight: 700;
  font-display: swap;
}

/* Non-critical icon font: no layout impact if it never loads */
@font-face {
  font-family: "ShopIcons";
  src: url("/fonts/shop-icons.woff2") format("woff2");
  font-display: optional; /* skip download entirely on slow connections */
}

body {
  font-family: "Inter", system-ui, -apple-system, sans-serif;
}

4. Preloading critical fonts deliberately

The browser only discovers @font-face declarations once the CSS has been parsed, and it usually starts the font download only after that, once it hits an element that actually needs the font. This late discovery point is the main reason for visible FOUT even on fast connections. A <link rel="preload" as="font"> in the document head breaks this chain: the browser fetches the file in parallel with the CSS, without waiting for it to parse, often cutting the time to the target font by several hundred milliseconds.

It's important to restrict preloading to the font weights actually visible above the fold; preloading a bold variant that's only used in the footer wastes bandwidth that more critical resources need. The crossorigin attribute is mandatory for font preloads, even for same-origin requests, since fonts are always loaded in CORS mode per spec; omit it, and the browser fetches the file a second time when it's actually used.


<!-- Preload only the above-the-fold font weights, crossorigin is mandatory -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-regular.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-bold.woff2" crossorigin>

<!-- Hyvä phtml: preload the variable font used for hero and product title -->
<link rel="preload" as="font" type="font/woff2"
      href="{{$block->getViewFileUrl('fonts/inter-var.woff2')}}" crossorigin>

<style>
  /* CSS declared after preload so the browser already has the bytes cached */
  @font-face {
    font-family: "Inter";
    src: url("/fonts/inter-var.woff2") format("woff2-variations");
    font-weight: 400 700;
    font-display: swap;
  }
</style>

5. Variable fonts to reduce file count

Classic web font setups load a separate file per weight and style: regular, bold, italic, bold italic, often across multiple weights. For a store with four to six weights, that quickly adds up to 300-500 KB of font data, all of it competing for the critical rendering path. Variable fonts encode the entire weight range, and sometimes width and italics too, into a single file with interpolatable axes. Instead of five separate 60 KB files, the browser loads a single file of often only 80-120 KB that covers every weight between 100 and 900.

The effect on font loading is doubly positive: fewer HTTP requests mean less connection-setup overhead, and because only one file needs to load, only one preload link is needed instead of several. On the CSS side, the weight range is declared as a range via font-weight: 400 700, and the running stylesheet can then use any intermediate value like font-weight: 550, which would be impossible with static fonts. The trade-off: variable fonts are larger per file than a single static weight, so switching pays off once several weights are actually in use.


/* One variable font file covers the entire weight axis 100-900 */
@font-face {
  font-family: "Inter Var";
  src: url("/fonts/inter-var.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-stretch: 75% 125%;
  font-display: swap;
}

/* Any intermediate weight is now usable without extra files */
.product-price {
  font-family: "Inter Var", system-ui, sans-serif;
  font-weight: 650;
}

.hero-headline {
  font-family: "Inter Var", system-ui, sans-serif;
  font-weight: 780;
}

6. size-adjust, ascent-override, and descent-override

Even with perfect font-display: swap, one problem remains: the fallback and target fonts almost never share identical metrics. Different x-height, advance width, and line height cause text to shift its line breaks during the font swap, pushing subsequent elements up or down, measured as Cumulative Layout Shift. The CSS descriptors size-adjust, ascent-override, descent-override, and line-gap-override in @font-face solve exactly this problem by adjusting a fallback font's metrics to approximate the target font as closely as possible.

The practical workflow: for the target font, say Inter, you pick a matching system fallback like Arial and analyze via a tool such as fontaine or the Capsize metric calculator how much its ascent, descent, and character width deviate. The calculated percentages are then defined as a separate @font-face rule for the fallback font and placed in the font-family stack directly before the target font. The result: the fallback text during the swap window already occupies nearly the same amount of space as the final text, so the layout jump on font swap practically disappears.


/* Target font */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}

/* Metric-matched fallback: Arial adjusted to mimic Inter's box model */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107.4%;
  ascent-override: 90.2%;
  descent-override: 22.8%;
  line-gap-override: 0%;
}

body {
  /* Fallback renders at near-identical width during the swap window */
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

7. Self-hosting vs. font CDN

Google Fonts and similar font CDNs offer convenient <link> integration, but they force an extra DNS lookup, TCP handshake, and TLS handshake to a third-party domain before the actual font download even starts, typically 100-300ms of extra latency on mobile connections. Since browsers introduced cache partitioning across domains, the older advantage of a user already having the Google Fonts file cached from another site no longer holds. For GDPR-relevant stores in the EU, directly embedding Google Fonts URLs is also considered problematic for data protection, since it transmits the visitor's IP address to Google.

Self-hosting solves both problems: the font files live on the same domain or CDN as the rest of the store, eliminating the extra connection setup and making preload hints work reliably, since rel="preconnect" to a third-party font domain is no longer needed. In Magento, fonts can simply be placed in the theme directory under web/fonts and referenced via getViewFileUrl(), so they're automatically versioned by setup:static-content:deploy and served through the store's own CDN with long cache headers. The only downside of self-hosting is the manual maintenance overhead when fonts get updated, which a simple build script in the theme repository can automate almost entirely.

8. The Font Loading API and document.fonts.ready

For cases where CSS alone isn't enough, such as synchronizing a loading animation precisely with font rendering or smoothly fading in a FOUT transition, the browser exposes the CSS Font Loading API via the global document.fonts object. It lets you query the load state of individual font faces programmatically and wait for all referenced fonts to finish loading, without relying on unreliable timeout heuristics.

A common pattern is to add a class like fonts-loading to the <html> element before loading, which enforces an understated fallback appearance via CSS, and to swap that class for fonts-loaded only after document.fonts.ready resolves. This enables a soft opacity transition for the font swap that reads as less abrupt than a hard jump, without replacing the native font-display: swap mechanism. It's important to use this pattern only additively, since the native browser fallback still has to apply when JavaScript is disabled.


// Wait for all referenced fonts to finish loading, then swap a CSS class
document.documentElement.classList.add('fonts-loading');

document.fonts.ready.then(() => {
  document.documentElement.classList.remove('fonts-loading');
  document.documentElement.classList.add('fonts-loaded');
});

// Check a specific font-face without waiting for the whole document
document.fonts.load('700 1em "Inter"').then((loadedFaces) => {
  if (loadedFaces.length > 0) {
    console.log('Bold Inter is available for rendering');
  }
});

// React to individual font load events for fine-grained control
document.fonts.addEventListener('loadingdone', (event) => {
  event.fontfaces.forEach((face) => {
    console.log(`Loaded: ${face.family} ${face.weight}`);
  });
});

9. font-display values compared side by side

Each font-display value strikes a different balance between text visibility, visual stability, and control over the brand typeface. The table below summarizes when each value is the right choice.

Value Block window Behavior Recommendation
auto Browser-dependent Unpredictable, often FOIT in Chrome Avoid
block ~3 s Short FOIT, then unlimited swap Only defensible for icon fonts
swap ~0 ms Immediate FOUT, text always visible Default for body copy
fallback ~100 ms Short swap window, then fallback fixed Good compromise for brand fonts
optional 0 ms Download can be aborted, no CLS Ideal for non-essential fonts

In practice, these values can be combined: swap for the primary font that carries the brand identity, and optional for decorative or secondary weights that carry no critical information. Together with preloading, variable fonts, and metric-matched fallbacks, this produces a font-loading strategy that produces neither invisible text nor disruptive layout shifts.

Mironsoft

Font-loading engineering and rendering performance for Magento and Hyvä stores

Ready to fix font loading without layout shift?

We analyze your current font-loading strategy, migrate to self-hosting with variable fonts, and set up metric-matched fallbacks so text is visible immediately and the font swap produces zero CLS.

Font audit

Analysis of every loaded font weight, file size, and font-display value

Self-hosting migration

Moving off font CDNs to performant, GDPR-compliant self-hosting

Variable font integration

Consolidating multiple weights into a single, optimized font file

10. Summary

Font loading without layout shift starts with understanding the three basic patterns FOIT, FOUT, and FOFT, and deciding which one makes sense for which font weight. font-display: swap is the right baseline for most text content, optional suits non-essential fonts, and a deliberately placed <link rel="preload"> noticeably shortens the time to the target font, because the download no longer waits for the CSS to be parsed first.

Variable fonts drastically reduce the number of files that need to load and thereby simplify preloading too, while size-adjust, ascent-override, and descent-override eliminate the actual layout shift at font swap by matching the fallback font's metrics to the target font. Self-hosting removes extra latency from third-party domains and reduces privacy risk compared to font CDNs. Combined, this produces a loading strategy where text is visible from the first second and the font swap no longer produces a noticeable jump.

Font Loading Without Layout Shift - The Essentials at a Glance

Choose font-display deliberately

swap for body copy, optional for non-essential fonts, avoid auto.

Preload critical fonts

<link rel="preload" as="font"> with crossorigin, above-the-fold only.

Use variable fonts

One file instead of many weights, fewer requests, simpler preloading.

Match fallback metrics

size-adjust, ascent-override, descent-override against font-swap CLS.

11. FAQ: Font Loading Strategies Without Layout Shift

1What is the difference between FOIT, FOUT, and FOFT?
FOIT keeps text invisible until fully loaded. FOUT renders immediately with a fallback and swaps later. FOFT loads a reduced variant and replaces it with the full weights.
2Which font-display value should I use for body copy?
In most cases swap: immediate rendering with a fallback font, swapped once the target font is available, text is never invisible.
3When should I use font-display: optional instead of swap?
For non-essential fonts like icon fonts, where the download can be aborted under network pressure without causing CLS or content loss.
4Why isn't font-display alone enough to prevent layout shift?
It only controls visibility, not space usage. Different metrics between fallback and target fonts still cause CLS, fixed by size-adjust and ascent-override.
5How does font preloading work and when is it worth it?
link rel=preload as=font loads in parallel with CSS parsing. Worth it for above-the-fold weights, often saves several hundred milliseconds.
6What are variable fonts and what's their advantage?
Encode multiple weights in a single file with interpolatable axes, substantially reducing file count and HTTP requests compared to static weights.
7How do I calculate values for size-adjust and ascent-override?
Via tools like Capsize or fontaine, which compare target and fallback font metrics and compute matching percentage values.
8Is self-hosting fonts better than a font CDN like Google Fonts?
Usually yes: saves handshake latency to a third-party domain, works reliably with preload, and avoids privacy concerns from IP transmission to third parties.
9What is document.fonts.ready used for in the Font Loading API?
Returns a promise resolved once all referenced fonts have loaded. Enables smooth transitions, but does not replace the native font-display mechanism.
10How many font weights should a Magento store load at most?
As few as possible, ideally one variable font file plus an optional italic weight. Every extra static weight increases load time and FOUT risk.