← All articles
🔤 PerformanceIntermediate · 9 min read

Font Loading Without Layout Shift

Three CSS declarations and one preload tag — the modern setup that gives you custom fonts without paying the LCP and CLS bill.

By Kajal Pansuriya · Reviewed by Kishan Vaghani · Published May 26, 2026

Custom fonts make a site feel branded. They also routinely cause LCP regressions and visible text shifts that show up in Cumulative Layout Shift scores weeks after launch. Both problems are solvable with three CSS declarations and one preload tag, and the solution has been stable in browsers for several years — most sites have just never been updated to use it.

Why fonts cause problems

When the browser parses your CSS and finds a @font-face rule referenced by visible text, it has to decide what to do while the font file downloads. The two extreme answers are both bad: hide the text until the font arrives (Flash of Invisible Text, FOIT) or render it immediately in a fallback and swap when the custom font is ready (Flash of Unstyled Text, FOUT, which causes layout shift because the fallback and custom font almost never have identical metrics).

The browser's choice is controlled by a single CSS property: font-display. Understanding the four values is the starting point.

The four font-display values

auto (browser default)

Browsers interpret auto as roughly equivalent to block — the text is invisible for up to three seconds while the font loads, then renders in the fallback if the custom font has not arrived. On a slow connection this means a blank page where the headline should be, which counts as the worst LCP experience the metric can record.

swap

Render with the fallback font immediately. Swap to the custom font whenever it arrives. This eliminates the LCP hit but introduces FOUT — the user sees text re-render and shift when the custom font loads, which shows up in CLS unless you combine this value with size-adjust (covered below). For most sites this is the correct value.

fallback

A 100ms block period during which the browser waits for the font, then renders in the fallback for the rest of the load. If the custom font arrives within a three-second window after that, swap in; otherwise stay with the fallback for the whole page lifetime. Balanced but rarely the best choice — it has the FOUT of swap on slow connections and the small block period of block on fast ones.

optional

A very short block period (around 100ms), then if the custom font is not ready, abandon it for this page load entirely. No swap, no shift. The best value for performance, the worst for branding consistency — first-time visitors on slow connections will see your fallback font and never the custom one.

The newer trick: size-adjust

The reason swap causes layout shift is that the fallback font and the custom font have different metrics — different x-height, different character widths, different line heights. When the swap happens, every line of text re-flows to match the new metrics.

The size-adjust descriptor on @font-face fixes this by scaling the fallback font to match the custom font's metrics. The text rendered in the fallback occupies the same horizontal and vertical space as the text will when the custom font swaps in, so the swap is invisible.

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;       /* tune to match Inter's x-height */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "Inter", "Inter Fallback", sans-serif;
}

The exact percentage depends on the font pair. Tools like Malte Ubl's font fallback generator measure the metrics of both fonts and produce the right descriptors automatically. Next.js uses the same approach internally for its next/font API.

Preloading the right weights

Even with the right font-display value, the custom font is not requested until the browser parses CSS, computes styles, and finds an element that actually uses it. That can be hundreds of milliseconds after the HTML arrives. A preload tag short-circuits this:

<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
/>

Preload only the weights that actually render above the fold. A typical site uses two or three weights total — usually regular and bold, sometimes a display weight for the headline. Preloading every weight you reference anywhere on the site wastes bandwidth and competes with the LCP image for the first slot in the network queue. Preloading nothing means the font fetch waits for CSS parse and the swap happens visibly late.

The crossorigin attribute is non-optional — fonts are always fetched in CORS mode, and without the attribute on the preload tag the browser will issue a second, non-CORS request and the preload will be wasted.

Self-host vs Google Fonts

Linking the Google Fonts CSS file from fonts.googleapis.com is convenient but adds a DNS lookup, a TCP connection, and a TLS handshake to the critical path before the font file itself is even requested. On a cold cache that is typically 200–400ms of additional latency on top of the actual font download.

Self-hosting puts the font on the same origin as your HTML. No extra DNS, no extra TCP, the same TLS connection that served the page. The font shows up in your normal performance budget and your normal CDN cache. The only thing you lose is the shared cache benefit when a returning user happens to have hit another Google-Fonts site that requested the exact same family — and browser cache partitioning has eliminated even that benefit since 2020.

The Google Fonts download tool gives you the woff2 files. Put them under /public/fonts on Next.js or your equivalent static path, write a @font-face rule that points at the local file, and the migration is done.

The modern best-practice setup

Putting all of it together — self-host, preload the above-the-fold weights, declare with font-display: swap, and add a size-adjusted fallback so the swap is invisible:

/* fonts.css */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-regular.woff2") format("woff2");
}

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

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

:root {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

Add the preload links in the document head for the regular and bold weights and the setup is complete. On a Next.js project, the next/font/local import does all of this automatically — including the size-adjust calculation — and is the right default if you are already on the framework.

What real numbers look like

A blog we audited last quarter was loading three weights of a custom serif from Google Fonts with font-display: auto. LCP on a 4G profile was 3.1s, almost all of which was the three-second block period waiting for the font. After switching to self-hosted woff2 files with font-display: swap, preloading the regular and bold weights, and adding a size-adjusted fallback, LCP dropped to 1.4s with no visible swap. The CSS file grew by about 400 bytes.

The habit that compounds

Fonts feel like a designer's problem — the choice of typeface, the weights, the kerning, the line height — but the metrics that matter for users live in the engineering pipeline. The team that ships the brand without paying for it is the one where the font-loading setup is part of the template, not an afterthought tacked on once the page is built. Apply the four pieces — host, preload, font-display, size-adjust — to your default layout once, and every page that inherits it gets the win for free.

Related reading

For the underlying metric definitions, see Core Web Vitals explained. The companion post on image optimisation covers the other half of asset performance. To verify your changes against real traffic, wire up real-user monitoring. The full performance silo is on the web performance topic page.

About the writers

Author

Kajal Pansuriya

Developer Educator, ShareCode

Developer educator at ShareCode. Writes the tutorial track — Python, JavaScript debugging, coding-interview prep, and the everyday code-quality habits that hold up in real codebases.

Python fundamentals & teaching beginnersJavaScript debugging & DevToolsCoding-interview preparationClean code & code review
Reviewed by

Kishan Vaghani

Founder & Lead Engineer, ShareCode

Founder of ShareCode. Writes the engineering deep-dives on this site — WebRTC, Firebase Auth, real-time sync, and the production patterns behind the editor itself.

Real-time collaboration & CRDTsWebRTC & low-latency mediaFirebase authentication & security rulesNext.js & full-stack JavaScript

Tuning fonts on a live site?

Paste your @font-face rules and your preload tags into a code space, share with a teammate, and walk through the four-piece checklist together. The size-adjust percentage almost always needs one round of measurement to land cleanly — easier with two pairs of eyes.

Open a code space