Auditing Font Payload per Page

Fonts accumulate. A site launches with a regular and a bold weight of one family; a year later it has four weights, italics, a second family for headings, a monospace family for code, an icon font from a component library, and preload links for faces that a redesign stopped using. Nobody decided to ship 300 KB of fonts; it happened one reasonable request at a time. Because fonts are cached, the cost is invisible to the team and paid in full by every first-time visitor.

This guide audits font payload per template on a static marketing and documentation site: which files each page downloads, which faces are actually used to render text, which downloads are wasted, and how to turn the result into a budget that CI enforces. It is part of Font Loading Strategies for Static Sites. The site is an Astro site of 410 pages with five templates.

Prerequisites

  • The built site served locally or on a preview URL.
  • Chrome DevTools, and Playwright for automation.
  • The CSS source, to trace @font-face rules and font-family declarations.

Step 1: Record Font Requests per Template

A Playwright script loads one representative page per template with a cold cache and records every font response:

// scripts/font-audit.ts
import { chromium } from 'playwright';
const templates = { home: '/', landing: '/pricing/', docs: '/docs/guide/install/', blog: '/blog/css-grid-at-scale/', api: '/docs/api/client/' };
const browser = await chromium.launch();
for (const [name, path] of Object.entries(templates)) {
  const context = await browser.newContext();
  const page = await context.newPage();
  const fonts: { url: string; bytes: number; preloaded: boolean }[] = [];
  page.on('response', async (res) => {
    if (res.request().resourceType() !== 'font') return;
    const body = await res.body();
    fonts.push({ url: res.url().split('/').pop()!, bytes: body.length, preloaded: res.request().isNavigationRequest() === false && (await res.request().headerValue('purpose')) === null });
  });
  await page.goto(BASE + path, { waitUntil: 'networkidle' });
  await page.mouse.wheel(0, 20000); await page.waitForTimeout(500);   // trigger fonts used lower down
  console.log(name, fonts.reduce((s, f) => s + f.bytes, 0), fonts.map((f) => f.url).join(' '));
  await context.close();
}

Scrolling to the bottom matters: fonts used only in the footer or in code blocks further down the page load late, but they still count toward the first-visit cost.

TemplateFont filesTotal WOFF2
Home7212 KB
Landing6178 KB
Docs8246 KB
Blog7204 KB
API reference8246 KB
WOFF2 bytes downloaded per template before the audit Horizontal bars of font bytes downloaded on a cold first visit per template. Docs 246 kilobytes. API reference 246 kilobytes. Home 212 kilobytes. Blog 204 kilobytes. Landing 178 kilobytes. Font bytes per template on a first visit Docs 246 KB API reference 246 KB Home 212 KB Blog 204 KB Landing 178 KB
Recorded with Playwright on a cold cache, scrolling to the bottom of each page.

Step 2: Compare Downloads With What Renders Text

A downloaded font is only useful if it renders text. DevTools' Computed pane lists "Rendered Fonts" for a selected element; for a whole page, a script can walk every text node and record the family, weight and style in use via getComputedStyle, then compare with the downloaded faces. On the docs template:

Downloaded faceUsed byVerdict
Inter 400body textneeded
Inter 600headings, navneeded
Inter 700one <strong> in the footerwasteful: 700 used by 1 element
Inter 400 italic<em> in contentneeded, but rare
Inter Display 800hero heading (home only)preloaded on every page
JetBrains Mono 400code blocksneeded
JetBrains Mono 700nothing@font-face matched a font-weight: bold on .token.keyword
Icon font4 icons in headerreplaceable by inline SVG

Three of eight files did no useful work, and two more could be avoided with small CSS changes.

Step 3: Find the Common Causes

The wasted downloads had four causes, all common:

  • A global preload for a page-specific face. Inter Display 800 was preloaded in the shared layout for the home page hero. Move page-specific preloads into the page's own head.
  • A single element triggering a weight. The footer's <strong> pulled in Inter 700 on every page. Either style strong at 600 site-wide, or accept synthesized bold for that rare case with font-synthesis: weight.
  • Syntax highlighting themes setting bold. The code theme made keywords bold, which requested JetBrains Mono 700. Setting font-weight: inherit for highlighted tokens removed it; the colour already distinguishes keywords.
  • An icon font for four icons. Replaced by inline SVG, as in SVG Icon Sprites vs Inline Icons.
Causes of wasted font downloads found in the audit Four causes. A global preload loaded the home hero display face on every page. One strong element in the footer triggered the 700 weight everywhere. The syntax highlighting theme set keywords bold, triggering the monospace bold face. An icon font was loaded for four header icons. Each fix was a small CSS or template change. Why pages downloaded fonts they did not need Loaded but unused display face preloaded globally mono 700 from bold tokens icon font for 4 icons 138 KB per first visit Used by almost nothing Inter 700 for one footer element italic for rare em text 49 KB per first visit
None of the four was a deliberate decision; each came from a sensible local change that nobody measured site-wide.

Step 4: Fix and Re-Measure

After the fixes — preload moved to the home page only, strong at 600, code tokens at inherited weight, icons inlined, the italic face kept but loaded without preload — the audit ran again:

TemplateBeforeAfter
Home212 KB, 7 files96 KB, 3 files
Landing178 KB62 KB
Docs246 KB91 KB
API reference246 KB91 KB

The remaining docs payload was Inter 400, Inter 600 and JetBrains Mono 400, subset to Latin with the approach in Subsetting Variable Fonts for Faster First Render, plus the italic when a page used it.

Font payload per template before and after the audit fixes Paired bars. Home went from 212 to 96 kilobytes. Landing went from 178 to 62 kilobytes. Docs went from 246 to 91 kilobytes. API reference went from 246 to 91 kilobytes. Font bytes per template, before and after Before audit After fixes Home 212 KB 96 KB Landing 178 KB 62 KB Docs 246 KB 91 KB API reference 246 KB 91 KB
Same Playwright script and cold-cache conditions before and after.

Step 5: A Font Budget in CI

The audit script becomes a CI check against the preview deploy: it records font bytes and file counts per template and fails if a template exceeds its budget (100 KB and 3 files for docs, 110 KB and 4 files for the home page) or if a font is downloaded that is not in an allowlist of expected faces per template. The allowlist catches the "one element triggered a new weight" regression immediately, with a message naming the face and the template. It runs alongside the budgets in Per-Template Performance Budgets. In the six months after it was added, it flagged three regressions: a new callout style with italic bold, a blog author box using 700, and a marketing page importing a second display family.

Fonts in Third-Party Content

The audit also surfaced fonts that the site's own CSS did not request: an embedded code playground loaded its own copy of a monospace font, and a newsletter signup widget loaded two Google Fonts weights from a third-party origin. Neither appeared in the site's stylesheets, so a CSS-based audit would have missed them; the network-based audit caught both. The playground got a facade that loads it on click, and the widget was replaced by a native form posting to an edge function, which also removed a third-party connection from every page.

Measured Impact

MeasureBeforeAfter
Font bytes, docs template, first visit246 KB91 KB
Font files, docs template83
Preloads per page31 (2 on home)
LCP p75 mobile, docs (field)1.9 s1.7 s
CLS p75 from font swap0.030.01
Font regressions caught in CI (6 months)—3

Pitfalls & Rollback

  • Auditing only the top of the page. Fonts used lower down still download on the first visit; scroll.
  • Auditing only the home page. Templates differ; audit one page per template.
  • Global preloads. Preload only what the current template needs above the fold.
  • Ignoring code and icon fonts. They are often the heaviest and least needed.
  • Rollback: each fix is a small CSS or template change and can be reverted individually; the CI budget can be switched to warn-only.

Conclusion

Font payload grows by accident and shrinks only when someone measures it per template. Record font downloads with a cold cache and a full scroll, compare them with the faces that actually render text, remove global preloads and stray weights, and hold the result with a per-template budget and allowlist in CI. On this site that cut first-visit font bytes by 63% without changing the design.

FAQ

How do I see which fonts a page downloads?

The Network panel in DevTools filtered by Font shows every font file with its size and initiator, and the Rendered Fonts section of the Computed pane shows which font each element actually uses. For a whole site, a Playwright script can record font requests per template.

Why would a page download a font it does not use?

Common causes are preload links for faces the page does not need, @font-face rules whose family matches a font-family used by a hidden or rarely shown element, bold or italic faces triggered by a single strong or em element, and icon fonts loaded globally.

What is a reasonable font budget?

For most content sites, 100 KB of WOFF2 on a first visit is generous and 50 KB is a good target, with no more than two font files needed for above-the-fold text. Sites using system fonts for body text can budget close to zero.

Are synthesized bold and italic acceptable?

For occasional use, often yes. Browsers can synthesize bold and oblique from a regular face, which avoids a download. Quality is lower than a real face, so it suits rarely used styles rather than headings. Control it with the font-synthesis property.

Filed underHow-tos