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-facerules andfont-familydeclarations.
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.
| Template | Font files | Total WOFF2 |
|---|---|---|
| Home | 7 | 212 KB |
| Landing | 6 | 178 KB |
| Docs | 8 | 246 KB |
| Blog | 7 | 204 KB |
| API reference | 8 | 246 KB |
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 face | Used by | Verdict |
|---|---|---|
| Inter 400 | body text | needed |
| Inter 600 | headings, nav | needed |
| Inter 700 | one <strong> in the footer | wasteful: 700 used by 1 element |
| Inter 400 italic | <em> in content | needed, but rare |
| Inter Display 800 | hero heading (home only) | preloaded on every page |
| JetBrains Mono 400 | code blocks | needed |
| JetBrains Mono 700 | nothing | @font-face matched a font-weight: bold on .token.keyword |
| Icon font | 4 icons in header | replaceable 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 800was 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 stylestrongat 600 site-wide, or accept synthesized bold for that rare case withfont-synthesis: weight. - Syntax highlighting themes setting bold. The code theme made keywords bold, which requested JetBrains Mono 700. Setting
font-weight: inheritfor 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.
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:
| Template | Before | After |
|---|---|---|
| Home | 212 KB, 7 files | 96 KB, 3 files |
| Landing | 178 KB | 62 KB |
| Docs | 246 KB | 91 KB |
| API reference | 246 KB | 91 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.
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
| Measure | Before | After |
|---|---|---|
| Font bytes, docs template, first visit | 246 KB | 91 KB |
| Font files, docs template | 8 | 3 |
| Preloads per page | 3 | 1 (2 on home) |
| LCP p75 mobile, docs (field) | 1.9 s | 1.7 s |
| CLS p75 from font swap | 0.03 | 0.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.
Related
- Parent: Font Loading Strategies for Static Sites — loading the fonts you keep well.
- Preloading Fonts Without Double Downloads — preload correctness.
- Using System Font Stacks to Skip Web Fonts — the zero-byte option.
- Variable Fonts vs Static Weights — fewer files for many weights.
- Auditing Unused Preloads — the same audit for all preloads.
Filed underHow-tos