Optimizing Web Fonts
Web fonts are often the primary culprit behind poor LCP and CLS scores. While building my new site with Astro, I realized that shipping entire font files—which contain thousands of glyphs for languages I don’t use—was a massive waste of bandwidth.
By subsetting, I stripped the weight down to only the characters I actually need, resulting in a 70% reduction in font payload.
Performance Comparison
Here is how the two strategies stack up for this specific font set:
| Strategy | Total Size | File Count | Best For |
|---|---|---|---|
| Static | 210 KB | 10 | Absolute performance & precision |
| Variable | 237 KB | 6 | Design flexibility & fluid weights |
Using Static Fonts
Install Fonts
pnpm add @fontsource/inter
pnpm add @fontsource/jetbrains-mono
pnpm add @fontsource/montserrat
pnpm add @fontsource/newsreader
Generate Subset Files
FONTS=(
"montserrat-latin-800-normal"
"inter-latin-400-normal"
"inter-latin-400-italic"
"inter-latin-500-normal"
"inter-latin-600-normal"
"inter-latin-800-normal"
"newsreader-latin-400-italic"
"jetbrains-mono-latin-400-normal"
"jetbrains-mono-latin-400-italic"
"jetbrains-mono-latin-800-normal"
)
for FONT in "${FONTS[@]}"; do
FAMILY="${FONT%-latin*}"
INPUT_FILE="node_modules/@fontsource/${FAMILY}/files/${FONT}.woff2"
OUTPUT_FILE="public/fonts/static/${FONT}.woff2"
pyftsubset $INPUT_FILE \
--unicodes="U+0000-007F,U+00A0-00FF,U+2010,U+2013,U+2014,U+2022,U+2026" \
--layout-features="kern,ccmp,calt,liga,case,tnum,zero" \
--notdef-outline \
--flavor=woff2 \
--output-file=$OUTPUT_FILE
echo "📦 Input: $INPUT_FILE"
echo "✅ Output: $OUTPUT_FILE"
echo "-------------------"
done
Define Font-faces
@font-face {
font-family: montserrat;
font-style: normal;
font-display: swap;
font-weight: 800;
src: url("/fonts/static/montserrat-latin-800-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/static/inter-latin-400-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: italic;
font-display: swap;
font-weight: 400;
src: url("/fonts/static/inter-latin-400-italic.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("/fonts/static/inter-latin-500-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("/fonts/static/inter-latin-600-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: normal;
font-display: swap;
font-weight: 800;
src: url("/fonts/static/inter-latin-800-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: newsreader;
font-style: italic;
font-display: swap;
font-weight: 400;
src: url("/fonts/static/newsreader-latin-400-italic.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: jetbrains-mono;
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/static/jetbrains-mono-latin-400-normal.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: jetbrains-mono;
font-style: italic;
font-display: swap;
font-weight: 400;
src: url("/fonts/static/jetbrains-mono-latin-400-italic.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: jetbrains-mono;
font-style: normal;
font-display: swap;
font-weight: 800;
src: url("/fonts/static/jetbrains-mono-latin-800-normal.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
Preload Font Files
---
const preloadFonts = [
"montserrat-latin-800-normal",
"inter-latin-400-normal",
"inter-latin-400-italic",
"inter-latin-500-normal",
"inter-latin-600-normal",
"inter-latin-800-normal",
"newsreader-latin-400-italic",
"jetbrains-mono-latin-400-normal",
"jetbrains-mono-latin-400-italic",
"jetbrains-mono-latin-800-normal",
];
---
{
preloadFonts.map((font, index) => (
<link
rel="preload"
href={`/fonts/static/${font}.woff2`}
as="font"
type="font/woff2"
crossorigin="anonymous"
fetchpriority={index < 2 ? "high" : "auto"}
/>
))
}
Using Variable Fonts
Install Fonts
pnpm add @fontsource-variable/inter
pnpm add @fontsource-variable/jetbrains-mono
pnpm add @fontsource-variable/montserrat
pnpm add @fontsource-variable/newsreader
Generate Subset Files
FONTS=(
"montserrat-latin-wght-normal"
"inter-latin-wght-normal"
"inter-latin-wght-italic"
"newsreader-latin-wght-italic"
"jetbrains-mono-latin-wght-normal"
"jetbrains-mono-latin-wght-italic"
)
for FONT in "${FONTS[@]}"; do
FAMILY="${FONT%-latin*}"
INPUT_FILE="node_modules/@fontsource-variable/${FAMILY}/files/${FONT}.woff2"
OUTPUT_FILE="public/fonts/variable/${FONT}.woff2"
pyftsubset $INPUT_FILE \
--unicodes="U+0000-007F,U+00A0-00FF,U+2010,U+2013,U+2014,U+2022,U+2026" \
--layout-features="kern,ccmp,calt,liga,case,tnum,zero" \
--notdef-outline \
--flavor=woff2 \
--output-file=$OUTPUT_FILE
echo "📦 Input: $INPUT_FILE"
echo "✅ Output: $OUTPUT_FILE"
echo "-------------------"
done
Define Font-faces
@font-face {
font-family: montserrat;
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("/fonts/variable/montserrat-latin-wght-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("/fonts/variable/inter-latin-wght-normal.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: inter;
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url("/fonts/variable/inter-latin-wght-italic.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: newsreader;
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url("/fonts/variable/newsreader-latin-wght-italic.woff2") format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: jetbrains-mono;
font-style: normal;
font-display: swap;
font-weight: 100 800;
src: url("/fonts/variable/jetbrains-mono-latin-wght-normal.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
@font-face {
font-family: jetbrains-mono;
font-style: italic;
font-display: swap;
font-weight: 100 800;
src: url("/fonts/variable/jetbrains-mono-latin-wght-italic.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
Preload Font Files
---
const preloadFonts = [
"montserrat-latin-wght-normal",
"inter-latin-wght-normal",
"inter-latin-wght-italic",
"newsreader-latin-wght-italic",
"jetbrains-mono-latin-wght-normal",
"jetbrains-mono-latin-wght-italic",
];
---
{
preloadFonts.map((font, index) => (
<link
rel="preload"
href={`/fonts/variable/${font}.woff2`}
as="font"
type="font/woff2"
crossorigin="anonymous"
fetchpriority={index < 2 ? "high" : "auto"}
/>
))
}
My Opinion
I would chose variable fonts. While the total payload is slightly higher than using static fonts, the flexibility is the real winner.
Having a single file that can handle everything from a normal font-weight: 400
for body to a heavier 800 for hero headers—without needing to manage multiple
static files—is a massive developer experience win. It’s a small price to pay
for a perfectly fluid typographic system.
NOTE Always include
crossorigin="anonymous"in your font preloads. Even for self-hosted fonts, browsers require this attribute; omitting it will cause the browser to fetch the font twice, doubling your load time.
Setting Font Families
This covers both variants, variable and static, and fallback to system fonts across different platforms.
Custom Families
:root {
--font-heading: montserrat, var(--font-sans);
--font-sans: inter, ui-sans-serif, system-ui, sans-serif;
--font-serif: newsreader, ui-serif, serif;
--font-mono: jetbrains-mono, ui-monospace, monospace;
}
System Families
:root {
--font-heading: var(--font-sans);
--font-sans: ui-sans-serif, system-ui, sans-serif;
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
}
Bonus: Using SCSS
If you use scss, the font-faces code can be reduced to:
$fonts: (
("montserrat", normal, 100 900),
("inter", normal, 100 900),
("inter", italic, 100 900),
("newsreader", italic, 200 800),
("jetbrains-mono", normal, 100 800),
("jetbrains-mono", italic, 100 800)
);
@each $family, $style, $weight in $fonts {
@font-face {
font-family: $family;
font-style: $style;
font-weight: $weight;
font-display: swap;
src: url("/fonts/variable/#{$family}-latin-wght-#{$style}.woff2")
format("woff2");
unicode-range:
U+0000-007F, U+00A0-00FF, U+2010, U+2013, U+2014, U+2022, U+2026;
}
}