Web Font Optimization: Loading, Rendering, and CLS
Custom fonts improve design but can slow LCP and cause CLS when loaded poorly. This guide covers the full optimization path: font-display strategies (swap, fallback, and optional), preloading critical fonts, WOFF2 subsetting, FOUT vs. FOIT, variable fonts, fallback metrics, and a practical setup that looks good and stays fast.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
A designer picks an elegant Inter typeface for you, or a custom Chinese serif font for the brand. On launch day, you open the page and stare at a blank screen for two seconds. Then the text suddenly snaps from the system Song-style font to the brand font you have been waiting for. The Lighthouse score drops by 15 points, CLS is red across the board, and the designer still says, "But it looks great."
Font optimization creates exactly this awkward situation: it does not affect functionality, but it is one of the easiest parts of the performance experience to overlook and one of the easiest to get wrong. You can see when an image fails to load; you can feel a font being downloaded, blocked, or swapped, but may struggle to explain what happened.
This article explains the entire Web font performance pipeline, from downloading and rendering to layout stability, and concludes with a practical solution you can implement.
1. The performance cost of custom fonts: good looks come at a price
System fonts (Microsoft YaHei on Windows, PingFang SC on macOS, and Roboto on Android) have "zero cost": they are already installed and can be rendered immediately. A custom font, or Web Font, imposes three costs:
1. Download cost. A font is a file and must travel over the network. A complete Latin woff2 file for one weight is roughly 30–100KB, which does not sound large. Chinese fonts, however, routinely reach several MB. The complete Source Han Sans set remains 5MB+ even after woff2 compression, making it larger than your entire JS bundle.
2. Rendering-block cost. When the browser encounters text, it must decide which font to use to draw it. If the CSS declares a Web Font that has not finished downloading, the browser faces a dilemma: should it leave the text blank while it waits for the font (FOIT), or render it with a system font first (FOUT)? With default behavior, most browsers hide the text for up to 3 seconds, leaving it invisible during that period.
3. Font-flashing cost (FOUT / FOIT). Once the font finally arrives, the text must "switch." The two fonts have different metrics, including x-height, character widths, and line height. At the moment of the switch, the width of the entire text block changes. If the text is in the main content flow, every subsequent element moves with it, producing CLS.
As you can see, fonts can directly undermine both LCP and CLS, two Core Web Vitals metrics. If the LCP element is a line of heading text, a 3-second font download means a 3-second LCP. If the font swap moves the layout, it contributes directly to CLS.
Do not misunderstand this as an argument against custom fonts. Brand recognition, readability, and design consistency may all require them. The point is that if you use them, you must use them properly.
2. FOUT vs. FOIT: two kinds of font flashing, and you must choose one
First, establish the two terms clearly:
- FOIT (Flash of Invisible Text): While the font loads, the text is not displayed at all; the browser renders a transparent placeholder. The user sees "blank space."
- FOUT (Flash of Unstyled Text): While the font loads, the browser displays the text using a system font and replaces it when the Web Font arrives. The user sees a "system font → brand font" transition.
Neither is perfect; there is only a tradeoff.
The problem with FOIT: Invisible text means the user waits for nothing. If the LCP element is text, this entire delay counts toward LCP. On a slow connection, users stare at a blank area and assume the page is broken. The worst case is when the font arrives just after the 3-second timeout: the user waits 3 seconds to see the system font, then sees it change one second later, paying the cost of both FOIT and FOUT.
The problem with FOUT: The text remains visible, but it "jumps." If the font metrics do not match at the moment of the swap, the layout shifts and CLS rises. The visual impression of the text "changing clothes" also feels unstable, which designers dislike.
The conclusion is clear: for performance, FOUT is almost always better than FOIT. Letting users see content immediately is always better than making them stare at a blank area. Google's own performance team and every web.dev guide recommend FOUT by default. The only reasonable use case for FOIT is the rare brand page where the font is so distinctive that a system font would destroy the brand identity, such as logo-level Coca-Cola lettering. Even then, you would be better off rendering it as an SVG/image.
The control that determines FOUT or FOIT is font-display, covered in the next section.
3. Choosing a font-display strategy: swap / fallback / optional
font-display is a descriptor inside @font-face. It tells the browser how to display text while the font is downloading. It defines two time windows: the block period (when text is invisible) and the swap period (when the fallback font is visible and can be replaced once the font arrives). Each value specifies a different combination of window lengths.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* This is the key line */
}
There are five values, but only three are commonly used and worth memorizing:
swap (most common): A very short block period (~100ms) + an unlimited swap period. This effectively forces FOUT: text appears almost immediately in a system font, and the Web Font replaces it whenever it arrives, even 10 seconds later. This is the choice for most projects. The tradeoff is that the font may swap late on a slow connection, causing noticeable CLS.
fallback (the best balance): A very short block period (~100ms) + a short swap period (~3 seconds). It means, "Use the system font after 100ms. If the font arrives within 3 seconds, replace it; if it still has not arrived, give up and keep the system font permanently." This gives the font a reasonable window without interrupting the user with a jump 10 seconds later. It is appropriate for body text: you want the brand font, but not at the expense of stability.
optional (the most restrained): A very short block period (~100ms) + no swap period. It means, "Give the font 100ms. If it cannot arrive in time, give up completely and use the system font for the rest of this page view." However, the font continues downloading and is cached. In other words, first-time visitors on slow connections see the system font without any flashing. On a return visit, the font is already cached and can be used within 100ms, so they see the brand font. This is genuine progressive enhancement: returning visitors get the font, while first-time visitors get stability, satisfying both groups.
Avoid block and auto in nearly all cases. block means "force FOIT for 3 seconds," while auto means "let the browser decide." Most browsers default to FOIT, so auto delegates the decision to the worst option.
How should you choose? Use this decision table:
| Use case | font-display | Reason |
|---|---|---|
| Heading / Hero / brand | swap | Heading text is an LCP candidate and must be visible immediately; swap when the font arrives |
| Body text / long-form content | fallback | Do not let a late font interrupt reading |
| Decorative / noncritical | optional | Something merely decorative must never damage the experience |
| Every situation | Do not use block / auto | FOIT is a performance killer |
4. Preload critical fonts: do not make fonts wait in line
When the browser discovers that an @font-face needs a woff2 file, it usually waits until it has parsed the CSS and encountered text that uses the font before sending the request. That request chain is sequential: HTML → CSS → font. Discovering a critical above-the-fold font earlier removes part of that wait.
That is what <link rel="preload"> does. It tells the browser, "You will definitely need this resource shortly, so download it now instead of waiting."
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Three pitfalls require special emphasis:
1. Do not omit crossorigin. Font resources use anonymous CORS mode by default. If the preload omits crossorigin, the preload and the actual request use mismatched CORS policies, causing the browser to discard the preloaded result and request the file again. The preload then accomplishes nothing. This is the subtlest and most common form of fake font optimization I have seen.
2. Preload only fonts genuinely used above the fold. Do not preload every weight in the entire font family. That is a pessimization because those files compete with above-the-fold content for bandwidth. The rule is to preload one Latin body-text weight, or at most one additional bold weight, and defer everything else.
3. Do not combine preload with font-display: optional blindly. The semantics of optional are "skip it if it cannot arrive," while preload insists that it "must arrive." Those instructions conflict. Preload is best paired with swap / fallback.
After configuring preload, ensure that the src path in @font-face exactly matches the preload's href, including letter case and query strings. Otherwise, the browser treats them as two different resources and the preload is ineffective.
5. Subsetting: the make-or-break requirement for Chinese font optimization
If the preceding sections describe "small optimizations for Latin fonts," subsetting is a matter of survival for Chinese websites.
The principle is simple: a font file contains thousands or tens of thousands of glyphs, but a page may actually use only a few hundred. Remove the unused glyphs and the file becomes smaller. That is subsetting.
Consider the numbers:
- Complete Source Han Sans SC set: ~5MB after woff2 compression
- Latin characters + digits + punctuation only: ~80KB
- 3,500 commonly used Chinese characters + Latin: ~400–600KB
- Only the characters used by one article: tens of KB
A 5MB font takes several seconds over 4G. A subset of a few hundred KB arrives within one second. The difference is an order of magnitude.
Toolchain:
pyftsubset(from fonttools): The de facto standard for command-line subsetting. You can specify characters to retain with--unicodes,--text, or--text-file, and apply woff2 compression at the same time.
# Subset from a text file and compress to woff2
pyftsubset SourceHanSansSC.otf \
--text-file=article-chars.txt \
--flavor=woff2 \
--output-file=subset.woff2 \
--layout-features='*' --no-hinting
glyphhanger: Uses Puppeteer to collect the characters that actually appear on your pages, then invokes pyftsubset. It is suitable for dynamic content.subfont: A performance-budget-oriented tool that automatically analyzes HTML/CSS and replaces fonts with subsets.
For Chinese content, I recommend choosing one of these three practical strategies or combining them:
Strategy A: Use system fonts and do not introduce a Web Font (most reliable). Define a carefully tuned font-family stack that uses each platform's native Chinese font, with no download required:
body {
font-family:
'Inter', system-ui,
-apple-system, 'PingFang SC', /* macOS / iOS */
'Microsoft YaHei', /* Windows */
'Noto Sans CJK SC', /* Linux / Android */
sans-serif;
}
This is sufficient for most Chinese content sites, and its performance is unbeatable: zero font requests and zero flashing.
Strategy B: Subset common characters for body text and load the brand font separately. Subset Source Han Sans to the 3,500 most common characters, which cover most body text, and use it as the body Web Font. For headings or logos, use a more aggressive subset containing only the characters that actually appear.
Strategy C: Dynamic subsetting by page content. During the build, scan the Chinese characters used in each article and generate a dedicated font subset for that article. This is the ultimate solution, but it requires more engineering and is best suited to high-volume news/blog sites. You can also follow Google Fonts' approach to CJK: split the font into more than 100 shards using unicode-range, and let the browser download only the shards needed above the fold.
A common misconception is that loading a CJK font such as Noto Sans SC through next/font/google means it has been "automatically subsetted." In reality, next/font performs automatic subsetting only for Latin. Chinese fonts are downloaded intact, placing several MB in the critical path. You must subset CJK fonts yourself and self-host them with next/font/local (see Section 8).
6. Variable Fonts: every weight in one file
Traditional fonts use one file per weight. Regular, Medium, and Bold mean three woff2 files, three requests, and three sets of stored data.
Variable Fonts, introduced in OpenType 1.8, use one file containing one or more design axes. The most common is the weight axis, wght. CSS dynamically selects the desired weight, and the font file calculates it through interpolation.
Benefits:
- Fewer requests: One file covers the entire 100–900 weight range, eliminating separate requests for each weight.
- Arbitrary weights: You can use an intermediate value such as
font-weight: 450, greatly improving design precision compared with the limited static weights available before. - Fewer total bytes: One variable woff2 is usually 30%–50% smaller than the combined size of four static weights.
Usage:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900; /* Key: declare the variable weight range */
font-display: swap;
}
body { font-weight: 400; }
h1 { font-weight: 720; } /* Any intermediate value works */
.eyebrow { font-variation-settings: 'wght' 550; }
The range syntax font-weight: 100 900 tells the browser, "This font supports any weight from 100 through 900." You can then request the interpolated weight with font-weight: 720 without storing a separate file for 700.
There are pitfalls as well:
- Do not assume that a variable font is always smaller. If your page uses only Regular and Bold, two static woff2 files may be smaller than one variable font because the variable file contains outline data for all intermediate states. As a rule, variable fonts become more economical when the page uses three or more weights.
- Legacy browser compatibility. IE and very old Safari versions do not recognize
format('woff2-variations'), so they require a fallback to static fonts. In 2026, this can generally be ignored. - The subsetting tool must support variable fonts. Use options such as
--with-zsopewithpyftsubsetto retain the variable tables rather than subsetting away the axes.
Variable fonts offer less value for Chinese because the size bottleneck in a Chinese font is the number of glyphs, not the number of weights. Solve subsetting first, then consider variable fonts.
7. Eliminating font-swap CLS with size-adjust and fallback metrics
This is the most easily overlooked yet most damaging part of font optimization.
Return to the earlier scenario: with font-display: swap, the system font renders the text first, and the Web Font replaces it when it arrives. The problem is that the two fonts have different character widths, x-heights, and line heights. During the swap frame, the total width of the text block changes, moving subsequent elements and increasing CLS.
That is why many people see Lighthouse's CLS score turn red after configuring font-display: swap.
The solution is to artificially adjust the fallback font's metrics so that it occupies the same amount of space as the Web Font. CSS provides a set of descriptors specifically for this purpose: size-adjust, ascent-override, descent-override, and line-gap-override.
/* Primary font */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
/* Metrics-adjusted fallback that occupies the same space as Inter */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 100.06%;
ascent-override: 90%;
descent-override: 22.32%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}
Here is how it works: before Inter arrives, Inter-fallback, which is actually Arial, renders the text. However, size-adjust scales its character boxes to nearly the same dimensions as Inter. Once Inter arrives, the text switches fonts while the box dimensions remain almost unchanged, reducing the layout shift until it is visually imperceptible.
The difficult part is calculating those percentages. Manual tuning is impractical, so real projects use tools:
fontpie: A CLI tool that accepts a font file and automatically calculates the override values.fontaine: A plugin for the Vite/Next ecosystem that scans your fonts and automatically generates fallback@font-facerules with zero configuration.@fontsourcefallback metrics: Some font packages already include fallback metrics.
The most important point is that this metric matching is most effective for Latin scripts. Because Chinese characters are square and generally monospaced, with metric differences concentrated mainly in line height, using a fixed line-height plus proper subsetting is usually enough to mitigate the problem. CLS from Chinese font swaps is addressed primarily by keeping subsets small, loading them quickly, and swapping early. The earlier the swap occurs, the more naturally subsequent content flow can absorb the shift.
8. Next.js next/font: the automatic option for font optimization
If you use Next.js, next/font handles most of the manual work from the preceding seven sections. It is currently the most convenient font solution in the React ecosystem.
next/font gets five things right:
1. Self-hosting eliminates third-party requests. next/font/google downloads Google Fonts at build time and stores them as static assets on your domain. Production makes no request to fonts.googleapis.com. This is both faster, because there is no cross-origin request and the files can use your CDN, and more compliant, because it is GDPR-friendly and does not disclose visitor IP addresses to Google.
2. Automatic preloading of critical weights. Fonts you use automatically receive <link rel="preload"> with the correct crossorigin setting.
3. Automatic subsetting (Latin). By default, only the Latin subset is bundled, keeping the file to tens of KB.
4. Automatic font-display configuration. It defaults to swap and can be changed.
5. Automatic CLS elimination (most important). Internally, next/font uses the size-adjust / ascent-override technique from the previous section to pregenerate a fallback whose metrics match your font. This is the source of the official "no layout shift" claim: you configure nothing, and the font swap does not cause CLS.
Basic usage with a Google font:
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter', // Expose it as a CSS variable
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
/* Use it in any CSS file */
body {
font-family: var(--font-inter), system-ui, sans-serif;
}
Self-hosting a local font, for a subsetted Chinese font:
import localFont from 'next/font/local';
const notoSc = localFont({
src: './fonts/noto-sc-subset.woff2', // The file you subsetted yourself
display: 'swap',
variable: '--font-zh',
fallback: ['system-ui', 'PingFang SC', 'Microsoft YaHei'],
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className={notoSc.variable}>
<body>{children}</body>
</html>
);
}
The recommended approach for Chinese fonts is embedded in that example: do not load Noto Sans SC with next/font/google, because it will not subset the font and will place several MB directly in the critical path. Instead, create your own subsetted woff2 file and self-host it with next/font/local, gaining the preload and CLS-elimination benefits of next/font.
In short, next/font is the correct default. Unless you have a specific reason, such as not using Next or using a font unavailable from Google Fonts that you do not want to host locally, do not write @font-face manually.
Summary
- Custom fonts impose three costs: downloading, rendering blockage, and swap flashing. They affect both LCP, by delaying visible text, and CLS, by shifting content during the swap. System fonts are the zero-cost ceiling; if you introduce a Web Font, you must do it properly.
font-displayis the central control: useswap(immediate display, unlimited swapping) for headings,fallback(swap within 3 seconds, then give up) for body text, andoptional(no swap on the first visit, font on return visits) for decorative text. Never useblock/autoto create FOIT.- Subsetting is the make-or-break requirement for Chinese fonts: complete sets are several MB, while subsets are only a few hundred KB, an order-of-magnitude difference. For CJK, do not expect any framework to "automatically subset" the font. Subset it yourself with
pyftsubset/glyphhanger, then self-host it withnext/font/local. - Preload only critical above-the-fold fonts, and always include
crossorigin; otherwise, the work accomplishes nothing. - Eliminating CLS requires matching fallback metrics with
size-adjust+ascent-override, or generating them automatically withnext/font/fontaine. If you configureswapwithout this matching, CLS will turn red.
To conclude in one sentence: font optimization has only two iron rules: show the text that should be visible as quickly as possible, and keep layouts that should be stable from moving. Follow both, and the design can reach users' screens without paying a performance tax.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.