Preloading & Resource Hints for Web Font Delivery
This guide is part of the Font Loading & Delivery Strategies area, and it addresses the single most common cause of slow text rendering: the browser discovers your critical font too late. By default a webfont is invisible to the network until the browser has downloaded the HTML, downloaded and parsed the CSS, built the CSSOM, matched a styled element to an @font-face rule, and only then requested the WOFF2 file. That chain routinely costs two to four serialized round trips before the first glyph is even requested — and if your Largest Contentful Paint element is text, every one of those round trips is added straight onto your LCP.
Resource hints break that dependency. A <link rel="preload" as="font"> tells the browser to fetch the font binary during HTML parse, in parallel with the stylesheet, so the font is already in cache by the time the CSS finishes and the paint can happen immediately. This guide defines preload, prefetch, and preconnect precisely, shows where each belongs, covers the HTTP Link header and single-page-app edge cases, and gives a verification-driven workflow so you never ship a hint that the browser silently ignores.
Start your diagnosis in Chrome DevTools. Open the Network panel, filter by Font, disable cache, and reload on a throttled "Fast 4G" profile. Hover the WOFF2 request and read its Start time relative to navigation. If your critical font starts downloading hundreds of milliseconds after navigation — well after the CSS has landed — its discovery is gated behind the CSSOM, and a preload hint is the fix. Confirm the diagnosis in the Performance panel: record a trace, find the LCP marker, and check whether the font request finishes just before it.
Preload vs Prefetch vs Preconnect
These three hints are constantly confused, and using the wrong one is worse than using none. They operate at different layers of the loading pipeline.
preconnectwarms a connection. It performs the DNS lookup, TCP handshake, and TLS negotiation for an origin ahead of time, but downloads nothing. It only helps when the font lives on a different origin than the document (a separate CDN host, or hosted Google Fonts). For same-origin fonts it is useless — the connection is already open for the HTML.preloadfetches a specific resource at high priority during HTML parse. This is the hint that actually pulls your critical font forward. It requires you to know the exact URL, so it only works for self-hosted (or otherwise statically-known) font files.prefetchis a low-priority, speculative fetch for a resource the next navigation or a later interaction will need. It is the wrong tool for an above-the-fold font and the right tool for, say, the bold weight a comment form reveals on click. The full trade-off is covered in preload vs prefetch vs preconnect for fonts.
The mental model: preconnect opens the door, preload carries the parcel in now, prefetch leaves the parcel on the step for later. For a critical, self-hosted, above-the-fold font the answer is almost always a single preload.
Baseline Configuration
Before optimising anything, get the minimum correct setup in place: a same-origin @font-face with an explicit font-display, and a single preload for the one weight your first paint needs. Everything else is tuning on top of this.
Baseline preload + @font-face
<!-- In <head>, BEFORE the render-blocking stylesheet that references the font -->
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
Three details make or break this baseline. First, crossorigin is required even for same-origin fonts, because every font fetch happens in anonymous CORS mode — omit it and the preloaded file lands in a separate cache bucket from the actual @font-face request, so the browser downloads the font twice. Second, the href must match the @font-face src URL byte-for-byte, or the preload is wasted and you get a "preloaded but not used" console warning plus a duplicate fetch. Third, type="font/woff2" lets a browser that does not support WOFF2 skip the preload rather than download a format it cannot use.
If your fonts live on a separate static-asset host rather than the document origin, add a preconnect to that host so the cross-origin connection is warm before the preload fires. Pair this with the right font-display value so that, even on the slow path, fallback text appears immediately instead of leaving a blank screen.
Step-by-Step Implementation Workflow
Each step ends with a verification check so you confirm the hint did what you intended rather than assuming it did.
Implementation Steps
- Identify the critical font(s). In DevTools, find the LCP element (Performance panel → LCP marker) and read its computed
font-family,font-weight, andfont-style. That exact face — and usually only that one — is your preload candidate. Verify: you can name the single weight/style your above-the-fold text renders in. - Subset before you preload. A preload is only as cheap as the file behind it, so cut the family to the codepoints you actually serve with unicode-range subset loading and
pyftsubset. Verify: the critical WOFF2 you are about to preload is under your budget (target < 50KB/subset). - Emit the preload, before blocking resources. Place
<link rel="preload" as="font" type="font/woff2" crossorigin>in<head>ahead of the render-blocking stylesheet, ideally right after<meta charset>. Verify: in the Network waterfall the font now starts during HTML parse, in parallel with the CSS, not after it. - Add
fetchpriority="high"(Chromium). Even preloads compete; mark the LCP font as the highest-priority font fetch. Verify: the Priority column reads "Highest" for that request in Chrome. - Confirm no duplicate download. Reload with cache disabled and check the Font filter. Verify: the font appears once; if it appears twice, the
href/srcmismatch or a missingcrossoriginis the cause, and there is no "preloaded but not used" warning in the console. - Set immutable cache headers. Serve
/fonts/*content-hashed withCache-Control: public, max-age=31536000, immutable. Verify: a second navigation reads the font from disk cache (Size column shows "(disk cache)"), so the preload cost is paid only once. - Re-measure LCP, lab and field. Run Lighthouse for a lab figure and watch real-user monitoring for the field. Verify: LCP drops (commonly 200–400ms on throttled mobile for text-LCP pages) and the "Preconnect to required origins" / render-blocking notes about fonts disappear.
Browser Compatibility & Fallback Matrix
| Capability | Chrome/Edge | Firefox | Safari | Notes |
|---|---|---|---|---|
rel="preload" for fonts |
50+ | 85+ | 11.1+ | Firefox shipped it late; older FF simply ignores the hint and falls back to normal discovery |
rel="preconnect" |
46+ | 39+ | 11.1+ | Safe to include everywhere; ignored if unsupported |
rel="prefetch" |
8+ | 2+ | 15+ | Long-supported in Chromium/Firefox; Safari support is recent |
fetchpriority="high" |
102+ | 119+ | 17.2+ | Progressive enhancement — unknown attribute is ignored, falls back to default preload priority |
crossorigin on font preload |
Required | Required | Required | Mandatory even same-origin; omission causes a double download in all engines |
WOFF2 (type="font/woff2") |
36+ | 39+ | 10+ | The type lets non-supporting engines skip the preload |
Because every hint degrades to "ignored" rather than "error" when unsupported, you can include preload, preconnect, and fetchpriority unconditionally — the worst case in an old engine is the original, un-accelerated discovery path.
Preloading via the HTTP Link Header
A <link rel="preload"> tag is not the only way to declare a preload — the exact same hint can be sent as an HTTP response header, and doing so at the edge or origin server has one real advantage: the browser sees it before it has parsed a single byte of the HTML <head>, because HTTP headers are available the instant the response starts streaming.
Link: </fonts/inter-latin-400.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin
On Nginx you emit this with add_header on the HTML response (not the font response):
location = /index.html {
add_header Link "</fonts/inter-latin-400.woff2>; rel=preload; as=font; type=\"font/woff2\"; crossorigin" always;
}
On a Cloudflare Worker or similar edge function, set it on the outgoing response before the HTML body is returned:
export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
const headers = new Headers(response.headers);
headers.append(
"Link",
'</fonts/inter-latin-400.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin'
);
return new Response(response.body, { ...response, headers });
},
};
The header form and the <link> tag form are functionally equivalent in every current browser — pick the header when your HTML is generated far upstream of a templating layer you do not control, or when a CDN/edge worker can inject it cheaper than re-rendering markup. Do not send both for the same URL; two identical preload declarations do not double the priority, they are simply redundant, though not harmful (the browser deduplicates by URL). One caveat: HTTP/2 Server Push, which used to be paired with the Link header via nopush-less preload hints, is deprecated in Chrome and removed from most CDNs — treat the Link header purely as a preload signal, never as a push trigger.
Preloading in Single-Page Apps and Client-Side Routing
Everything above assumes a full document navigation, where the <head> is parsed once, up front. In a single-page app the second and later "page" the user sees is a client-side route change with no new HTML document, so a static <link rel="preload"> written into the original index.html only ever covers the fonts needed for the first screen.
Two patterns handle the rest:
- Route-level fonts, prefetched ahead of navigation. If route B introduces a heavier weight or a display family not used on route A, prefetch it (not preload — there is no render blocking it) when the user is likely to navigate there, for example on link hover or viewport-intersection of the link. This is the client-side-routing equivalent of a preload: the resource lands before it is needed, without competing with the current route's critical fetches.
function prefetchRouteFont(href) {
const link = document.createElement("link");
link.rel = "prefetch";
link.as = "font";
link.type = "font/woff2";
link.crossOrigin = "anonymous";
link.href = href;
document.head.appendChild(link);
}
// Called from an onMouseEnter / onTouchStart handler on route links.
- Preload only fonts the shell itself needs. Anything used across every route (the body font, the nav font) belongs in the original document's
<head>as a normal preload, because it is needed on the very first paint regardless of which route the user lands on. Route-specific display faces should never be preloaded from the initial document — that competes with the shell fonts for bandwidth on a route the user might not even visit.
A related trap is client-side-rendered frameworks that inject <link> tags via a useEffect/onMount hook rather than in static HTML: because that code only runs after the JS bundle has parsed and executed, the "preload" fires after the font would have been discovered by CSS anyway, so it buys nothing. If you cannot put the hint in the server-rendered <head>, use the Link HTTP header described above instead — it is available before any JavaScript runs.
Code Configuration Examples
HTML preload tag (self-hosted, Chromium priority bump)
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">
Preconnect for fonts on a separate asset host
<!-- Only needed when the font origin differs from the document origin. -->
<link rel="preconnect" href="https://static.example.com" crossorigin>
<link rel="preload" href="https://static.example.com/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
Variable-font @font-face the preload feeds
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
JS-driven loading for non-critical weights (prefetch alternative)
// Load a secondary weight after first paint without blocking the critical path.
const bold = new FontFace('Inter', 'url(/fonts/inter-700.woff2)', {
weight: '700',
style: 'normal'
});
bold.load().then((face) => {
document.fonts.add(face);
document.documentElement.classList.add('inter-bold-ready');
});
The CSS Font Loading API in the last snippet is the controlled way to bring in weights that are not needed for first paint — it keeps them off the critical path entirely instead of forcing them into a preload that would steal bandwidth from your LCP resource.
Two competing critical fonts (heading display face + body text face)
A common regression happens on pages where a large hero heading uses a display face and the body copy below the fold uses a different family — both technically "above the fold" on a tall viewport. Preloading both defeats the point of preloading at all, because the two files now split the same early-priority bandwidth. Rank them by which one contributes to the actual LCP node:
<!-- The hero heading is the measured LCP element: preload it at high priority. -->
<link rel="preload" href="/fonts/display-700.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">
<!-- Body text renders below the LCP node and can tolerate normal priority. -->
<link rel="preload" href="/fonts/body-400.woff2" as="font" type="font/woff2" crossorigin>
Only the LCP-contributing font gets fetchpriority="high"; the second preload still starts early (during HTML parse) but yields bandwidth to the first when the network is the bottleneck. If you have three or more above-the-fold faces, that is a strong signal to consolidate down to one family with a variable font instead of preloading multiple static files.
Common Pitfalls
- Preloading every weight. Each preload competes for bandwidth at the most contended moment of the page load. Preloading three or four weights can regress LCP by starving the one resource the first paint needs. Preload the single above-the-fold weight and let the rest load normally.
- Missing
crossorigin, causing a double download. Font fetches are always anonymous-CORS, so a preload withoutcrossoriginis cached under a different key than the real fetch. The font downloads twice and the preload buys you nothing. hrefnot matching the@font-facesrc. A trailing query string, a different case, or an absolute-vs-relative mismatch makes the browser treat them as two URLs. You get a "preloaded but not used" warning and a duplicate request.- Using
preconnectfor same-origin fonts. The connection to your own origin is already open from the HTML request; a same-originpreconnectdoes nothing but add a useless<link>. Reserve it for genuinely cross-origin font hosts. - Preload placed after render-blocking scripts. A
<link rel="preload">only accelerates discovery if the browser sees it early. Put it high in<head>, before blocking<script>and the main stylesheet. - Preloading without subsetting. Pulling a 200KB unsubsetted family forward just moves a large download earlier. Subset first so the preload carries a sub-50KB file, then accelerate it.
- Injecting the preload via client-side JavaScript. A
<link rel="preload">created inside a mounted-component effect runs after the JS bundle has already parsed and executed — by then, normal CSS-driven discovery would have found the font at roughly the same time. Preloads only pay off when they land in the server-rendered<head>or an HTTPLinkheader. - Overriding a legitimate
preconnectwith a straydns-prefetch.dns-prefetchonly resolves DNS, skipping the TCP/TLS handshakepreconnectperforms; using it as a substitute on a font CDN origin leaves the connection cold and buys back only a fraction of the round trip.
Frequently Asked Questions
When should I use preload versus prefetch for web fonts?
Use preload for the font your initial paint needs — it is a high-priority fetch that runs during HTML parse and lands before the CSS finishes, so it directly improves LCP on text-LCP pages. Use prefetch only for fonts a later navigation or post-interaction state will need (a bold weight revealed on click, a font for the next likely page, or the next route in a single-page app). prefetch is a low-priority, speculative hint and is the wrong tool for anything above the fold.
Does preloading a font block the main thread?
No. preload triggers a network fetch only; it does not block HTML parsing or run any main-thread work. The single main-thread cost of a webfont — parsing the file and re-laying-out text once it is applied — happens whether or not you preload, and font-display: swap keeps text visible in a fallback while that happens. Preloading simply makes the file arrive sooner.
How do I stop preload from causing a double download?
Two things must hold: the preload href must match the @font-face src exactly, and the preload must carry crossorigin (because font fetches are anonymous-CORS, even same-origin). If either is missing the browser keeps two separate cache entries and fetches the font twice. Verify in the Network panel that the font appears exactly once with no console warning.
Should I preload hosted Google Fonts?
You generally cannot preload them usefully — the actual WOFF2 URL is hidden inside Google's CSS and is version-rotated, so there is no stable URL to name in a preload. The most you can do for the hosted path is preconnect to both fonts.googleapis.com and fonts.gstatic.com. Self-hosting removes this limitation entirely, which is one reason it is the faster default for critical text; see the trade-off in depth in Google Fonts vs self-hosting.
Can I preload a font from a service worker instead of a <link> tag?
A service worker cannot add a <link rel="preload"> to a document that already loaded, but it can pre-warm the Cache Storage entry for a font on install, so that the next visit's fetch resolves from the service-worker cache instead of the network. That is a different mechanism from HTML preload — it helps repeat visits, not the very first one — and it does not replace a real preload hint for first-time visitors.
Does the Link HTTP header work the same as the <link> tag?
Yes, in every browser that supports font preload at all. The header is parsed as soon as the response begins streaming, before the HTML body (and therefore before a <link> tag inside it) is even available, so it can start the font fetch marginally earlier. Use it when an edge worker, reverse proxy, or CDN can attach the header more cheaply than modifying the HTML template, and never send the header and the tag for the same URL — the hint is deduplicated by URL, not duplicated in priority.