Font Loading for Multilingual Headless Sites
This guide belongs to Core Web Vitals Optimization and covers a performance problem that grows with every locale: fonts. A Latin-only site can load one or two small font files. A site serving Greek, Cyrillic, Arabic, Hebrew, Devanagari, Japanese and Korean needs different glyphs for each, and a naive setup ships all of them to everyone, or ships the wrong ones to some, causing slow text rendering, reflow and layout shift.
Fonts affect Core Web Vitals in two ways. Text is often the LCP element, especially on article and landing pages, and it cannot paint in the web font until the font file has loaded. And when a fallback font is replaced by the web font with different metrics, lines rewrap and blocks change height, which counts as layout shift. Both effects are larger for scripts with big font files, which are exactly the scripts a multilingual site adds.
The Problem
A software company added Japanese and Arabic to a site that used one web font family for all text. The font vendor’s full Japanese file was over two megabytes per weight, and the site loaded three weights on every Japanese page. Mobile LCP on Japanese pages was 4.8 seconds against 2.0 on English pages. Arabic pages had a different problem: the fallback font’s metrics differed so much from the web font that every paragraph rewrapped when the font loaded, giving Arabic pages a CLS of 0.18.
How to Load Fonts per Locale
Split fonts by script with unicode-range. Declare one @font-face per script subset, each with its unicode-range. Browsers download only the subsets whose characters appear on the page. A German page never downloads the Cyrillic subset, even though the same stylesheet declares it.
Preload only what the locale needs. The server knows the page’s locale, so it can preload the one or two font files certain to be used above the fold, and nothing else. Preloading every subset defeats the purpose of splitting.
Match fallback metrics. Declare a fallback @font-face that points to a local system font with size-adjust, ascent-override and descent-override tuned to the web font. When the web font replaces it, lines keep their length and blocks keep their height.
Consider system fonts for CJK. Japanese, Chinese and Korean system fonts are good on every major platform, and web fonts for these scripts are very large. Many sites use system fonts for CJK text, or a web font only for headings with a subset of the characters actually used.
Implementation
The stylesheet declares one face per script, all under the same family name, plus a metric-matched fallback.
/* fonts.css */
@font-face {
font-family: "Brand";
src: url("/fonts/brand-latin-400.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122;
}
@font-face {
font-family: "Brand";
src: url("/fonts/brand-cyrillic-400.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: "Brand";
src: url("/fonts/brand-arabic-400.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF;
}
/* Fallback tuned to the web font's metrics, so swapping does not reflow text. */
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 104.5%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
:root { --font-text: "Brand", "Brand Fallback", system-ui, sans-serif; }
:lang(ja), :lang(zh), :lang(ko) { --font-text: system-ui, "Hiragino Sans", "Noto Sans CJK JP", "Yu Gothic", sans-serif; }
body { font-family: var(--font-text); }
The layout preloads the locale’s primary subset only.
// app/[locale]/layout.tsx (excerpt)
const PRELOAD: Record<string, string[]> = {
en: ["/fonts/brand-latin-400.woff2"],
de: ["/fonts/brand-latin-400.woff2"],
ru: ["/fonts/brand-cyrillic-400.woff2", "/fonts/brand-latin-400.woff2"],
ar: ["/fonts/brand-arabic-400.woff2"],
ja: [], // system fonts for text
};
export default async function LocaleLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }> }) {
const { locale } = await params;
const dir = ["ar", "he", "fa"].includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<head>
{(PRELOAD[locale] ?? PRELOAD.en).map((href) => (
<link key={href} rel="preload" as="font" type="font/woff2" href={href} crossOrigin="anonymous" />
))}
</head>
<body>{children}</body>
</html>
);
}
Tools such as font subsetters and fallback metric generators produce the subset files and override values from the original font; many frameworks’ font helpers do both automatically.
Content-aware subsetting
For headings in a brand font in CJK languages, subset the font to the characters actually used in the site’s headings. A build step collects all heading text from the CMS per locale, generates a subset file with those characters, and regenerates it when content changes. The resulting files are often tens of kilobytes instead of megabytes. Characters not in the subset, for example in a heading published after the last build, fall back to the system font, which is acceptable for a short time and fixed at the next build or through a webhook-triggered regeneration.
Configuration Reference
| Script | Strategy | Preload |
|---|---|---|
| Latin, Cyrillic, Greek | web font subsets by unicode-range |
primary subset for the locale |
| Arabic, Hebrew | web font subset, metric-matched fallback, dir="rtl" |
primary subset |
| Devanagari, Thai | web font subset or system font | primary subset if web font |
| Japanese, Chinese, Korean | system fonts for text, content-subset brand font for headings | none, or heading subset |
| All | font-display: swap with matched fallbacks |
never preload unused subsets |
Gotchas & Edge Cases
- Mixed-script content. A Russian page with English product names needs both Cyrillic and Latin subsets;
unicode-rangehandles this automatically, but preload only the dominant one. - Punctuation and symbols. Currency signs, quotation marks and dashes are often in a different range than the letters. Include them in the subset for the locales that use them, or they render in the fallback font.
- Font synthesis. When a weight or style is missing, browsers synthesize bold and italic, which looks poor in some scripts. Load the weights you use, or disable synthesis with
font-synthesis. - Line height across scripts. Scripts differ in vertical metrics; a line height tuned for Latin can clip Arabic diacritics or Devanagari marks. Set line height per
:lang()where needed.
Worked Example
The software company switched Japanese body text to system fonts and kept its brand font only for Japanese headings, subset from heading text in the CMS to 38 kilobytes. It split its Latin, Cyrillic and Arabic faces by unicode-range, preloaded only the locale’s primary subset, and generated metric-matched fallbacks for Latin and Arabic. Japanese mobile LCP fell from 4.8 to 2.2 seconds, and Arabic CLS from 0.18 to 0.03, with no visible change in design quality that the design team considered significant.
The design team’s review was the step that took longest. They compared the system font rendering of Japanese body text with the previous web font on real articles and agreed that readers would not notice a difference in quality, while they would notice pages that took more than four seconds to show text.
Testing Fonts Across Locales
Font problems appear only in the locales that trigger them, so testing in the default language is not enough. Add a visual regression test that renders key templates in each script, with realistic long content, and compares screenshots after the web font has loaded and, separately, with web fonts blocked, which shows the fallback rendering and the size of any reflow. Check that no text renders in empty boxes, which reveals missing glyphs in a subset. In field data, segment LCP and CLS by locale, and watch for regressions after changes to the font stack, the subsets or the CMS content that feeds heading subsets. A quick lab trace per script, looking at when font requests start and finish relative to the first text paint, confirms that preloads are working.
Rollout Checklist
- Split each font by script with
unicode-rangeunder one family name. - Preload only the primary subset for each page’s locale.
- Generate metric-matched fallbacks for every web font.
- Use system fonts for CJK body text; subset brand fonts for headings from CMS content.
- Set
langanddircorrectly so:lang()rules and shaping apply. - Test each script for missing glyphs, reflow and line-height clipping.
Frequently Asked Questions
Is font-display: optional better than swap?
It avoids layout shift by never swapping late, but readers on slow connections may never see the brand font. With good metric-matched fallbacks, swap gives the brand font without meaningful shift.
Should fonts be self-hosted?
Usually yes, for control over subsets, caching and preloads, and to avoid a third-party connection on the critical path.
How do we handle user-generated content in many scripts?
Rely on unicode-range subsets and system fonts as fallback for scripts you do not support explicitly. Never preload for such content.
How many weights should we load?
Two are usually enough for text, regular and bold. Each extra weight is another file per script subset.
Do variable fonts help?
One variable font file can replace several weights, which reduces requests. Subset it by script just like static fonts.