Preloading Critical Fonts Without Blocking LCP
This guide is part of the Preloading & Resource Hints section, which sits under the broader Font Loading & Delivery Strategies blueprint. It solves one precise failure: the browser discovers a render-critical font too late — only after it has fetched and parsed the stylesheet — so the text in your Largest Contentful Paint element renders hundreds of milliseconds later than it should, pushing LCP past the 2.5s target.
Problem Statement
A web font referenced inside a CSS @font-face rule is not requested when the HTML arrives. The browser must first download the stylesheet, build the CSSOM, match a selector to an element on the page, and only then realise it needs that specific font file. On a typical page this discovery happens 300–600ms into the load, and the font fetch is serialized after the CSS request that revealed it — a classic critical-request chain. If the LCP element is a heading or paragraph in that font, the largest paint waits for a fetch that never needed to be last. A <link rel="preload"> hint moves the font request to the very start of the connection, in parallel with the CSS, closing the gap. The trap is over-preloading: every high-priority font you declare competes for bandwidth with the LCP image, so an unbounded preload list trades one LCP regression for another.
Prerequisites
Before adding a single preload hint, confirm the baseline is correct — preloading a misconfigured font only fetches the wrong thing faster:
- WOFF2 assets are served with
Content-Type: font/woff2andCache-Control: public, max-age=31536000, immutable, ideally with content-hashed filenames. - Every
@font-facecarries an explicitfont-displayvalue. Set font-display values toswapfor body text so fallback text paints immediately during the fetch. - You know which font file the LCP element actually uses. Inspect the computed
font-familyandfont-weightof that element in DevTools — preload only that file, not the whole family. - The font URL in the preload exactly matches the URL in the
@font-face src. A trailing query string, a different CDN host, or a casing difference produces two separate fetches.
Implementation: Preload Plus font-display Swap
Place the preload hint as early as possible in <head>, before the stylesheet link, so the fetch starts during HTML parsing rather than after CSSOM construction. Pair it with font-display: swap so the page never holds text hostage to the network.
Render-critical font preload with matching @font-face
<head>
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font"
type="font/woff2" crossorigin fetchpriority="high">
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF;
}
body { font-family: 'Inter', system-ui, sans-serif; }
</style>
<link rel="stylesheet" href="/css/app.css"
media="print" onload="this.media='all'">
</head>
The <link rel="preload" as="font"> line is load-bearing: it tells the preload scanner to fetch the file at high priority during HTML parse, long before the CSSOM names it. The crossorigin attribute is mandatory even for same-origin fonts — font fetches use anonymous CORS mode, and a preload without crossorigin is treated as a different request than the CSS-initiated one, so the browser discards the preloaded bytes and fetches the font a second time. type="font/woff2" lets non-supporting browsers skip the hint instead of wasting a fetch. fetchpriority="high" (Chromium) nudges the font above the LCP image only when the font genuinely drives the largest paint. The inline @font-face registers the family without waiting for an external stylesheet, and the media="print" swap on app.css defers non-critical CSS off the critical path. Keep this list to one or two files — the Regular weight and, if the LCP text is bold, the Bold weight.
Timeout and Error-Handling Variant
A preload can fail or stall: the CDN times out, the file 404s after a deploy, or the network is too slow for the font to matter. Use the CSS Font Loading API to bound the wait and fall back gracefully instead of leaving the page in a half-swapped state.
Bounded font load with timeout fallback
const FONT_TIMEOUT = 2000; // ms — beyond this, keep the fallback
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('font-timeout')), ms))
]);
}
if ('fonts' in document) {
withTimeout(document.fonts.load('1rem Inter'), FONT_TIMEOUT)
.then(() => document.documentElement.classList.add('fonts-loaded'))
.catch((err) => {
// Timeout or fetch error: stay on the metric-matched fallback stack.
document.documentElement.classList.add('fonts-failed');
if (window.performance && performance.mark) {
performance.mark('font-load-failed');
}
console.warn('Inter not applied:', err.message);
});
}
document.fonts.load() returns a promise that resolves when the face is ready, but it has no built-in deadline, so Promise.race against a timeout caps how long you privilege the web font over the fallback. On success, a fonts-loaded class lets CSS upgrade the type; on timeout or a network error the catch adds fonts-failed, which keeps the page on a metric-matched fallback stack so layout never shifts when a late font finally lands. The performance.mark gives you a field signal — query it from your RUM to count how often the font misses its budget. Because swap already shows fallback text, this variant only governs the upgrade, never the first paint.
Verification
Confirm the preload earned its place in the Network panel rather than assuming it helped:
- Open DevTools → Network → filter Font, then hard-reload with cache disabled.
- Check the Initiator column for the critical font — it should read your preload
<link>, notapp.css. If it shows the stylesheet, the hint is too late or the URL does not match. - Confirm the font appears exactly once. A second row for the same URL means
crossoriginis missing or mismatched — the classic double-fetch. - In the Priority column the preloaded font should be
High; secondary weights should sit atLow. - Run Lighthouse: "Preload key requests" and "Avoid chaining critical requests" should both clear, and the LCP element's font should no longer appear in the critical chain. Add a CI font budget (e.g. total font transfer < 120KB) so a fourth preload added later trips an alert.
The ladder above is the whole strategy in one picture: the critical weight and the LCP image share the top tier, while every other weight and decorative subset is deferred so it cannot starve the largest paint. The table below maps each preload decision to its effect on LCP.
| Decision | LCP effect | Rule of thumb |
|---|---|---|
| Preload Regular only | Largest text paints ~300ms sooner | Default for body-text LCP |
| Preload Regular + Bold | Both heading and body upgrade together | Only if LCP text is bold |
| Preload 4+ weights | Image LCP starved, net regression | Never preload non-critical weights |
No crossorigin |
Double-fetch wastes the preload | Always include crossorigin |
font-display: optional on hero |
Font may never appear on slow nets | Use swap for critical text |
Common Pitfalls
- Omitting
crossorigin. The preloaded response is discarded and the CSS engine fetches the font again — you pay twice and gain nothing. It is required even same-origin. - Preloading more than two fonts. Each high-priority font competes with the LCP image for bandwidth on a cold HTTP/2 connection; three or four preloads routinely regress LCP rather than improve it.
- Using
font-display: optionalon hero text. On slow connections the browser may skip the font entirely, so your branded heading silently renders in the fallback. Reserveoptionalfor non-critical body copy. - Mismatched
typeattribute. A wrong or missingtype="font/woff2"triggers a browser warning and, on some engines, the hint is ignored — the preload silently does nothing. - Preloading a font not used above the fold. The hint burns priority on bytes the first viewport never paints. Audit the LCP element's computed font before adding the hint, and split glyphs with unicode-range subsets so the preloaded file stays small.
FAQ
Does preloading a font block the main thread?
No. A preload initiates a background network fetch and does not touch the main thread. The only main-thread cost is font parsing and the layout pass when the face is applied, and font-display: swap keeps text visible throughout, so the user never sees a blocked render.
How many fonts should I preload for the best LCP? One or two render-critical files — typically Regular, plus Bold only if the LCP element is bold. Beyond that you saturate the connection and starve the LCP image or first script. If a page seems to need more, the real fix is usually subsetting to shrink each file or rendering fewer distinct weights above the fold.
Why does Lighthouse still flag font preloading after I added the hint?
The usual causes are a missing crossorigin, a type mismatch, or a URL that does not exactly match the @font-face src, all of which produce a double-fetch. Check the Network panel for two rows with the same font URL — that duplicate is the tell-tale sign the preload is not being reused.