Preloading Fonts Without Double Downloads
Preloading the main web font is one of the most common performance recommendations for static sites, and one of the most commonly broken. The idea is sound: the browser only discovers a font after it has downloaded the CSS, parsed it, built the render tree and found text that uses the family, so a <link rel="preload"> in the <head> lets the font start downloading hundreds of milliseconds earlier. But a preload that does not match the later font request exactly is not reused. The browser downloads the file a second time, and the page ends up slower than it was without the preload.
This guide shows the three ways a font preload fails to match, how to spot each one in DevTools, and a build-time pattern that keeps preloads and @font-face rules pointing at the same hashed URL. It is part of Font Loading Strategies for Static Sites.
Prerequisites
- Self-hosted font files — preloading a third-party font CSS file adds a connection and rarely helps; see Self-Hosting Google Fonts to Eliminate Layout Shift.
- Chrome DevTools, or a WebPageTest run with a waterfall.
- Access to the base layout template that renders
<head>.
How a Preload Is Matched
When a preload response arrives, the browser keeps it in the memory cache keyed by URL, destination and request mode. When the CSS later asks for the font, the browser looks for an entry with the same key. A match is reused instantly; anything else is a cache miss and a new request.
The three keys give three failure modes:
- Mode. Font requests are always made in CORS mode, even for files on your own origin. A preload without
crossoriginis made in no-cors mode, so the two never match. - URL. The preload says
/fonts/inter.woff2but the bundler rewrote the@font-facesource to/_astro/inter.a1b2c3.woff2. Or the CSS adds?v=2. Different strings, different cache entries. - Format. The preload points at a
.wofffile while the browser picks the.woff2source listed first insrc. Only one of them is ever used.
A fourth, milder problem is a preload for a font the page never uses — a bold weight preloaded on a template where nothing is bold. It is downloaded once, but for nothing, and it competes with the LCP resource.
The Correct Tag
For a self-hosted WOFF2 file, a correct preload looks like this:
<link rel="preload"
href="/fonts/inter-latin-400.a1b2c3.woff2"
as="font"
type="font/woff2"
crossorigin>
Each attribute has a job. as="font" sets the destination and priority; without it the preload is fetched at low priority and cannot be matched. type="font/woff2" lets browsers that cannot use the format skip the download. crossorigin with no value means anonymous, which is what @font-face uses; same-origin files need no CORS headers for this to work. If the fonts live on a separate domain, that host must send Access-Control-Allow-Origin.
Spotting a Double Download
Open DevTools, disable the cache, filter the network panel by Font and reload. A healthy page shows one request per font file, started early with the initiator listed as the HTML document. A broken preload shows two requests for the same file name: one early from the document, one later from the stylesheet.
Chrome also logs a console warning when a preloaded resource is not used within a few seconds of the load event: "The resource … was preloaded using link preload but not used within a few seconds." Treat that warning as a failing test.
Generating Preloads From the Build
Hand-written preload tags break the moment a bundler adds a content hash to font file names — which is what you want for caching; see Cache Busting with Content-Hashed Filenames. The fix is to derive the preload URL from the same import that feeds the @font-face rule.
In Astro or any Vite-based build, import the font file with the ?url suffix. The import returns the final hashed URL, which you can place in both places:
---
import interRegular from '../fonts/inter-latin-400.woff2?url';
---
<link rel="preload" href={interRegular} as="font" type="font/woff2" crossorigin />
<style define:vars={{ interRegular: `url(${interRegular})` }}>
@font-face {
font-family: 'Inter';
src: var(--interRegular) format('woff2');
font-display: swap;
}
</style>
Astro's experimental fonts API and packages such as @fontsource combined with a small layout helper follow the same principle. In Hugo, read the fingerprinted resource once with resources.Get "fonts/inter.woff2" | fingerprint and use its .RelPermalink in both the preload and an inline @font-face block. In Eleventy, pass the hashed name through a data file written by the asset step.
Choosing What to Preload
Preload only fonts that render above the fold on first paint, and usually only one or two files. On a documentation site that is typically the body regular weight; headings in a bold weight may justify a second preload if the H1 is the LCP element. Italic, monospace and extra weights should load normally when the CSS requests them.
Preloads also interact with font-display. With swap, a preload shortens the time the fallback font is visible and reduces the size of the swap. With optional, a preload is almost required: the font only gets roughly 100 milliseconds to arrive, and without an early start it rarely makes it on the first visit. The trade-off is covered in font-display: optional vs swap.
Keeping It Correct in CI
Add a check that runs against the built output rather than trusting reviewers to notice:
- Parse every HTML file's
<link rel="preload" as="font">tags and assert each hascrossorigin. - Assert each preload
hrefexists in the output directory — a stale hash means a 404 and a wasted request. - Assert each preloaded file is referenced by at least one
@font-facerule in the emitted CSS.
The same checks can live in a Lighthouse CI run: the preload-fonts and uses-rel-preload audits flag missing and unused preloads, and a budget can fail the build; see Setting Up Lighthouse CI for a Static Site.
Measured Impact
On a 1,200-page documentation site built with Astro, the base layout preloaded two fonts without crossorigin and one italic weight that no page used above the fold. After adding crossorigin, deriving URLs from imports and dropping the italic preload, the font bytes downloaded per cold view fell from 168 KB to 84 KB, lab LCP on the docs template improved from 1.9 to 1.5 seconds, and field LCP p75 on mobile dropped by 240 milliseconds over the next four weeks.
Pitfalls & Rollback
- Preloading the Google Fonts stylesheet. It points at a CSS file on another origin whose font URLs change; self-host instead.
- Using
crossorigin="use-credentials". Fonts are fetched anonymously; a credentialed preload will not match. - Preloading from a CDN font host without CORS headers. The preload succeeds, but the font request fails CORS; check the response headers.
- Preload plus
mediaqueries. A preload for a font only used at wide breakpoints downloads on phones too; add amediaattribute or skip it. - Rollback: remove the preload tags. The page falls back to discovering fonts through CSS, which is slower but never downloads twice.
Conclusion
A font preload helps only when it is reused, and reuse needs the same URL, the font destination and CORS mode. Add crossorigin, generate the preload URL from the same import as the @font-face rule, preload one or two above-the-fold weights, and check the built output in CI. Done that way, the preload removes several hundred milliseconds from text rendering instead of quietly adding a second download.
FAQ
Why does my preloaded font download twice?
Fonts are always fetched in CORS mode. A preload without the crossorigin attribute is made in no-cors mode, so its response cannot be reused by the @font-face request and the browser fetches the file again. A URL that differs by even a query string has the same effect.
Do I need crossorigin on a font preload from my own domain?
Yes. Font requests use CORS mode even for same-origin files, so the preload must match with crossorigin. For same-origin files the anonymous value is enough and no CORS headers are needed on the server.
How many fonts should I preload?
Usually one or two — the weights used above the fold, typically the body regular and perhaps a heading weight. Every preload competes with the LCP image and critical CSS for early bandwidth, so preloading every weight slows first render.
How can I tell if a preload was wasted?
Chrome logs a console warning when a preloaded resource is not used within a few seconds, and the DevTools network panel shows two requests for the same font file. Filter the network panel by Font and look for duplicates or an unused preload.
Related
- Parent: Font Loading Strategies for Static Sites — the full font policy.
- Metric-Matched Fallback Fonts with size-adjust — making the swap invisible.
- font-display: optional vs swap — which descriptor needs a preload most.
- Subsetting Variable Fonts for Faster First Render — shrinking the file you preload.
- Auditing Unused Preloads — finding wasted hints site-wide.