Fonts are one of the most overlooked resources in performance work. The hero image is carefully compressed, the JavaScript is split, yet the page still shows a blank gap where the headline should be, then the text "jumps" into another typeface and pushes the layout down. Vietnamese sites have an extra trap: a font that looks great on plain letters but breaks on "ệ" or "ữ". This post is how I handle fonts end to end — and why each step matters.

How fonts affect LCP and CLS#

When the browser meets text that uses a web font that hasn't loaded yet, it has to pick between two imperfect options:

  • FOIT (Flash of Invisible Text): hide the text until the font arrives. Users see a gap. If a large headline is the LCP element, LCP gets pushed back by however long the font takes.
  • FOUT (Flash of Unstyled Text): render the text in a fallback font immediately, then switch to the web font when it loads. Text shows up early, but on the swap the text size changes — lines get longer or shorter, wrap at different points — and everything below moves. That's CLS.

In other words: FOIT hurts LCP, FOUT hurts CLS. The goal of this post is to get the upside of FOUT (early text) while paying almost nothing in layout shift, and to make the font arrive as early as possible so both problems shrink.

Fonts also cost real bandwidth. A full character set in four weights can be a few hundred KB — more than all of a page's CSS. And the browser only discovers a font after downloading and parsing the CSS, and only once it sees an element that actually uses it, so fonts tend to sit deep in the loading chain.

font-display: choose your trade-off#

font-display inside @font-face controls how the browser behaves while waiting. It splits the wait into two periods: block (text is hidden) and swap (text uses the fallback, but still switches to the web font if it arrives in time).

ValueBlock periodSwap periodWhen to use it
autoBrowser decides (usually like block)Browser decidesRarely worth leaving as the default
blockShort (about 3 seconds)InfiniteIcon fonts, where fallback text is meaningless
swapVery shortInfiniteBrand fonts, headings — text should show now and definitely switch
fallbackVery shortShort (about 3 seconds)Body text: if it's too slow, keep the fallback rather than swap mid-read
optionalVery shortNoneAbsolute layout stability; the font is used only if it's available almost immediately

What I usually do:

  • swap for the main font when the design depends heavily on it, combined with fallback metric overrides (later in this post) so the swap barely moves anything.
  • optional for pages that prioritise stability above all. The first visit may only show the fallback, but the font gets cached and appears instantly next time. With preloading, the font often makes it in time even on the first visit.

swap isn't automatically the "correct" answer. It guarantees early text, but without fallback metric overrides it's exactly where your CLS comes from. Treat swap and metric overrides as a pair.

Preloading the critical font#

Because fonts are discovered late, the most effective way to load them early is to tell the browser directly in the HTML:

HTML
<link
  rel="preload"
  href="/fonts/inter-var-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

Three details that are easy to get wrong:

  • crossorigin is required, even when the font is on the same domain. Fonts are always fetched in CORS mode; a preload without crossorigin creates a request that doesn't match, and the browser downloads the font twice.
  • The URL must match exactly the one in @font-face, query string included.
  • Preload one or two files only — the fonts for above-the-fold content, usually the main font in the regular weight. Preloading everything means they all compete for bandwidth with the LCP image and CSS, and in the end nothing is prioritised.

Use WOFF2 only. Every modern browser supports it and it compresses noticeably better than WOFF or TTF; there's no reason to serve other formats on the web anymore.

Subsetting and unicode-range — the Vietnamese angle#

A complete font holds thousands of glyphs: extended Latin, Cyrillic, Greek, symbols... Your page uses a small fraction. Subsetting trims a font down to the glyphs you need, and unicode-range tells the browser which file covers which characters, so a file is only downloaded when the page actually contains characters in its range.

This is where Vietnamese needs attention. Large font services usually split fonts into subsets like latin, latin-ext and vietnamese. Vietnamese text needs both the latin and vietnamese subsets:

  • Letters like "à", "á", "â", "ê", "ô" are in Latin-1, part of the latin subset.
  • But "ă", "đ", "ơ", "ư" and most letters with stacked diacritics like "ế", "ệ", "ữ" live in other ranges (for example the Latin Extended Additional block, U+1EA0 to U+1EF9), part of the vietnamese subset.

Declare only latin and the browser draws the missing characters with a fallback font — leaving you with words that are half one typeface, half another. This slips through review easily when testers only look at English copy.

CSS
/* latin — character ranges shortened for illustration */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var-latin.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
}

/* vietnamese — downloaded only when the page contains characters in these ranges */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var-vietnamese.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
    U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+1EA0-1EF9, U+20AB;
}

Both files share one font-family, so your CSS just says font-family: "Inter" as usual and the browser picks files by character. On a Vietnamese page both files get downloaded — so preload both if this font is above the fold.

Vietnamese text can be precomposed (NFC, where "ệ" is a single character) or decomposed (NFD, "e" plus separate combining marks). Content pasted from some sources arrives as NFD and renders with misplaced marks in some fonts. Normalising to NFC with text.normalize("NFC") when saving is a good habit.

Variable fonts#

Traditional fonts need one file per weight and style: Regular, Medium, Semibold, Bold, plus italics — easily six to eight files. A variable font packs a whole weight range (and sometimes width or slant) into one file, declared with a range like font-weight: 100 900.

Variable fonts aren't automatically smaller: a variable file is usually heavier than a single static one. They win once you use two or three weights or more — one file replaces several, there are fewer requests, and you're free to use font-weight: 550 if the design wants it. If the whole site only uses Regular and Bold, two well-subset static files may well be lighter. Measure instead of guessing.

Self-hosting vs a third-party CDN#

The classic argument for Google Fonts was "users might already have the font cached from another site". That's no longer true: major browsers now partition the HTTP cache by site, so a font downloaded on site A isn't reused on site B.

Self-hostedThird-party CDN
ConnectionsReuses the site's connectionExtra DNS, TCP, TLS to another origin (often two: CSS and font files)
PreloadingEasy, stable URLsHarder, font file URLs may change
CachingYou control the headersNo cross-site cache advantage anymore
PrivacyNo user IPs sent to a third partyCan be a legal concern in some regions (the EU, for example)
EffortYou subset and update yourselfClose to zero

I self-host almost always. Serve fonts with Cache-Control: public, max-age=31536000, immutable and put a hash or version in the filename so they can be cached forever. If you stay on a third-party CDN, at least add a preconnect to the origin serving the font files.

Fallback metric overrides to reduce layout shift#

This technique makes the biggest difference to CLS. The idea: create a "fake" @font-face pointing at a system font (like Arial), then tune its metrics so it takes up the same space the web font will. When the swap happens, the text still looks different, but lines don't get longer or wrap differently — and the layout stays put.

The descriptors available in @font-face:

  • size-adjust — scales glyphs by a percentage so the average width matches the web font.
  • ascent-override, descent-override — adjust the space above and below the baseline, which affects line height.
  • line-gap-override — adjusts the extra gap between lines.
CSS
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  /* Illustrative values — compute them from your font's real metrics */
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

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

Don't eyeball these numbers. They're computed from both fonts' real metrics (units per em, ascender, descender, average glyph width); there are tools and libraries that do it automatically — and as you'll see next, Next.js does it for you. Browser support for these descriptors varies, but browsers that don't support them simply ignore them, so there's no harm in using them.

next/font in Next.js#

If you're on Next.js, next/font wraps almost everything above into a few lines:

  • Automatic self-hosting: with next/font/google, font files are downloaded at build time and served from your own domain. Users' browsers never make a request to Google.
  • Subsetting as declared, with correct unicode-range.
  • Preloading of the subsets you declare.
  • Metric-adjusted fallbacks: Next.js generates a fallback @font-face with size-adjust and overrides computed from the font's metrics.
app/layout.tsx
import type { ReactNode } from "react";
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin", "vietnamese"], // don't forget "vietnamese"
  display: "swap",
  variable: "--font-sans",
});

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="vi" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Then use var(--font-sans) in your CSS or Tailwind config. For purchased or in-house fonts, next/font/local gives the same benefits: point it at the WOFF2 files in your repo and Next.js handles the rest.

A few notes:

  • Call the font function once, at module level, and reuse it everywhere. Calling it in several files creates several font instances.
  • Pick fonts that have a vietnamese subset. Not every font on Google Fonts does; check before the design locks in a typeface.
  • If a font is only used in a small part of the page that isn't above the fold, set preload: false so it doesn't compete with the main content.

Key takeaways#

  • FOIT delays LCP, FOUT causes CLS — the goal is early text without layout jumps.
  • Choose font-display deliberately: swap paired with metric overrides, optional when stability is paramount.
  • Preload the one or two most critical font files, remember crossorigin, ship WOFF2 only.
  • Subset fonts and use unicode-range; Vietnamese pages need both the latin and vietnamese subsets.
  • Variable fonts pay off once you need two or three weights or more — measure file sizes first.
  • Prefer self-hosting: cross-site caching is gone, and you save a connection.
  • Tune fallbacks with size-adjust, ascent-override and descent-override computed from real metrics.
  • In Next.js, use next/font with subsets: ["latin", "vietnamese"].