Optimizing Web Fonts

Shubham Gulati

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:

StrategyTotal SizeFile CountBest For
Static210 KB10Absolute performance & precision
Variable237 KB6Design 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;
  }
}