Why font files are heavier than they need to be
A web font is a collection of vector-shaped glyphs, so two factors determine its file size: the complexity of each glyph's vector paths and the total number of glyphs in the font. A font like Open Sans, for example, contains 897 glyphs covering Latin, Greek, and Cyrillic scripts. Character set coverage is an important consideration when choosing a font — Google's Noto family aims to cover all world languages, but the full set amounts to over 1.1GB compressed. Most pages use a fraction of those glyphs, which suggests a significant optimization opportunity.
Container formats and compression
Two font container formats are commonly used today: WOFF and WOFF2, with both enjoying broad support across modern browsers. WOFF2 uses Brotli for its internal compression and offers up to 30% better compression than WOFF. For legacy browser support (e.g., Internet Explorer 11), WOFF can be served as a fallback, but EOT and TTF are no longer necessary and may result in longer download times. Alternatively, for legacy or constrained devices, it may be more performant to skip web fonts entirely and rely on system fonts.
It's also worth noting that some font formats include additional metadata such as font hinting and kerning that isn't needed on all platforms. Services like Google Fonts maintain platform-specific variants of each font and serve the optimal one automatically.
Optimizing @font-face declarations
The @font-face CSS at-rule defines the location of a font resource, its style characteristics, and the Unicode codepoints it covers. Multiple declarations can be combined into a single logical font family. Each declaration includes a src descriptor, which specifies a prioritized list of formats and local sources:
local()references locally installed fonts, bypassing the network entirely — the fastest option when the font is present on the user's system.url()points to external fonts and may include aformat()hint so the browser can skip unsupported formats without downloading them.
@font-face {
font-family: 'Awesome Font';
font-style: normal;
font-weight: 400;
src: local('Awesome Font'),
url('/fonts/awesome.woff2') format('woff2'),
/* Only serve WOFF if necessary. Otherwise,
WOFF 2.0 is fine by itself. */
url('/fonts/awesome.woff') format('woff');
}
@font-face {
font-family: 'Awesome Font';
font-style: italic;
font-weight: 400;
src: local('Awesome Font Italic'),
url('/fonts/awesome-i.woff2') format('woff2'),
url('/fonts/awesome-i.woff') format('woff');
}
The browser only downloads fonts that are needed for the page's CSS Object Model (CSSOM). For each required font, it first checks for a local copy; if none exists, it iterates the url() sources in order, consulting format hints to determine which resource is appropriate.
Variable fonts
Developers serving multiple font variants (regular, bold, italic) should consider variable fonts: a single file contains all interpolatable variants. The file is larger than an individual static variant but smaller than the sum of all distinct files. If a variable font would be too large for the critical rendering path, consider serving individual critical variants first and downloading others lazily. Variable fonts are now supported in all modern browsers.
Unicode-range subsetting
The unicode-range descriptor lets you split a large font into subsets for different language scripts, so users only download the glyphs required to render the current page content. Range values come in three forms:
- Single codepoints like
U+416 - Intervals like
U+400-4ff, specifying start and end codepoints - Wildcard ranges like
U+4??, where?matches any hexadecimal digit
@font-face {
font-family: 'Awesome Font';
font-style: normal;
font-weight: 400;
src: local('Awesome Font'),
url('/fonts/awesome-l.woff2') format('woff2');
/* Latin glyphs */
unicode-range: U+000-5FF;
}
@font-face {
font-family: 'Awesome Font';
font-style: normal;
font-weight: 400;
src: local('Awesome Font'),
url('/fonts/awesome-jp.woff2') format('woff2');
/* Japanese glyphs */
unicode-range: U+3000-9FFF, U+ff??;
}
Nearly all browsers support unicode-range. For older browsers without this support, "manual subsetting" is required: strip out glyphs that aren't used on the page and serve what remains as a single self-contained resource.
To generate the subsets themselves, use the open-source pyftsubset tool. Several font servers (such as Google Fonts) do automatic subsetting by default, and many font services allow manual specification of subsets via query parameters.
Font selection and synthesis
Font families are made of stylistic variants: regular, bold, and italic, often with multiple weights per style. Each variant may use entirely different glyph shapes with different spacing or geometry. In browsers, when the page requests a font weight with no exact match, the closest available face is substituted — heavier requested weights map to heavier available faces, and lighter weights map to lighter faces. If no stylistically matching face exists (e.g., an italic face isn't declared), the browser synthesizes one from an available face.
Since each variant requires a separate download, it's worth keeping the number of stylistic variants you actually serve small. Declaring, for instance, just a 400-weight regular face and a 700-weight bold face may be sufficient, so long as your CSS never requires intermediate weights without an available match:
@font-face {
font-family: 'Awesome Font';
font-style: normal;
font-weight: 400;
src: local('Awesome Font'),
url('/fonts/awesome-l.woff2') format('woff2');
/* Latin glyphs */
unicode-range: U+000-5FF;
}
@font-face {
font-family: 'Awesome Font';
font-style: normal;
font-weight: 700;
src: local('Awesome Font'),
url('/fonts/awesome-l-700.woff2') format('woff2');
/* Latin glyphs */
unicode-range: U+000-5FF;
}
Synthesized variants are generated from a single font face and can look noticeably different from the real thing. Browsers vary in how they generate faux bold and oblique faces, so results can be unpredictable across different platforms. It's a problem best avoided by carefully planning the set of variants the declaration serves.
Key points for shrinking your font payloads
- Track every font you load: Limit the number of families and variants per family. Fewer requests and smaller total payloads mean a faster, more consistent page.
- Skip outdated formats: EOT and TTF are obsolete and add weight. WOFF is only justified for Internet Explorer 11 support. For modern browsers, WOFF 2.0 alone delivers the best compression and simplicity.
- Subset strategically: Split fonts by
unicode-rangeto send only the glyphs a page needs. Define subsets at the script level (for example, Latin, Cyrillic) to avoid redundant or overlapping downloads across different pages. - Prioritize local font cache: Start your
srclist withlocal('Font Name'). When the font is already installed on the user's device, the browser will not make an HTTP request at all. - Verify with Lighthouse: Run the Lighthouse audit to check for text compression issues.
Impact on LCP and CLS
Text nodes often qualify as candidates for Largest Contentful Paint (LCP). Keeping your font files lean is therefore a direct way to help your users see content as soon as possible, which benefits your LCP score.
If you are worried about text visibility while these larger files load, the font-display property offers strategies to avoid invisible text. But be careful: the swap value can trigger significant layout shifts, which hurt your Cumulative Layout Shift (CLS). Where possible, use the optional or fallback values to minimize that risk.
When your typography is central to your brand's look, consider preloading font files. This gives the browser a head start on the network request and shortens the swap period (with font-display: swap) or the blocking period (when no font-display is set).



