Why Font Preload Needs the crossorigin Attribute

This guide is part of the Font Preloading & Resource Hints guide, itself under the Font Loading & Delivery Strategies area.

The problem: a preload that fetches the font twice

You add <link rel="preload" as="font" href="/fonts/inter-var.woff2" type="font/woff2"> to the <head> expecting the browser to warm the cache before the @font-face rule ever asks for the file. Instead, DevTools shows the font downloaded twice — once for the preload, once for the actual @font-face fetch — and the network waterfall gets worse, not better. The byte count for that single WOFF2 file doubles, and on a mobile connection the second copy can arrive after first paint, which is exactly the outcome preload was supposed to prevent.

The cause is a single missing attribute: crossorigin. Fonts are the one resource type where the browser always performs an anonymous CORS fetch, even when the file is same-origin. If the preload <link> doesn't declare crossorigin, its request uses a different fetch mode than the one the actual font load will use, the two requests land in different cache-key buckets, and the browser has no choice but to fetch the resource again. This single-attribute gotcha is one of the most common reasons a preload measured in a Lighthouse report shows "preloaded but not used" or "duplicate request."

This matters more than it looks like a one-line HTML fix would suggest. Font files are frequently among the largest render-blocking-adjacent assets on a page — a variable WOFF2 covering a full Latin weight range can easily run 80–150KB — so paying for that transfer twice is not a rounding error. On a throttled "Slow 4G" profile in DevTools, a 120KB variable font that should load once in roughly 400ms instead ties up the connection for closer to 800ms across two serialized fetches, because the second request can't start warming until the first has been discarded as unusable by the font engine. That is 400ms of extra time before the browser can paint the real text, directly inflating whatever Largest Contentful Paint number your hero heading contributes.

Prerequisites

  • A @font-face declaration already in your CSS, referencing a WOFF2 file (see Font Format Strategy if you haven't settled on WOFF2 as your primary format).
  • A <link rel="preload"> hint already present, or about to be added, for the same file.
  • Access to Chrome DevTools' Network panel to verify request counts before and after the fix.
  • Familiarity with how the browser prioritizes font-display swap/optional periods, since a duplicated fetch effectively lengthens whichever period you've configured.

Why fonts are always fetched anonymously

The @font-face src fetch algorithm in the CSS Fonts spec mandates that font resources are always requested in crossorigin (CORS-anonymous) mode, with no credentials attached, regardless of whether the URL is same-origin or cross-origin. This is a deliberate security decision: fonts can leak information through glyph metrics and hinting data, so browsers treat every font fetch — including same-origin ones — as an anonymous, credential-less CORS request.

A <link rel="preload"> hint, on the other hand, defaults to a "no-cors" (or same-origin, depending on browser) fetch mode unless you tell it otherwise. When the preloaded resource is destined to be consumed as="font", the browser needs the preload's fetch to match — byte for byte, credentials mode included — the fetch that the font engine will perform later. If the two fetch modes differ, the Fetch spec's cache-matching algorithm treats them as different requests. The result is two separate network entries for the same URL: one triggered by the preload, one triggered by the actual glyph rasterization when text needs to render.

This is different from every other preloadable resource type. A preloaded script or stylesheet defaults to a fetch mode that already matches how the browser will eventually request it, so those hints work correctly with nothing more than rel="preload" and the right as value. Fonts break that pattern because the CSS Fonts spec deliberately overrides the destination's default fetch mode, and that override is invisible in the markup — there's nothing in a <link> tag that tells you fonts are special this way short of knowing the spec detail. That's precisely why this attribute is so easy to omit: the HTML looks complete and valid, the page renders, and only a careful look at the Network panel or a console warning reveals the duplicated request.

Implementation

The fix is to always pair as="font" with a bare crossorigin attribute, even for fonts hosted on your own origin:

<head>
  <link
    rel="preload"
    href="/fonts/inter-var.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  >
  <link rel="stylesheet" href="/styles/main.css">
</head>
@font-face {
  font-family: "Inter Var";
  src: url("/fonts/inter-var.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}

body {
  font-family: "Inter Var", system-ui, sans-serif;
}

Line by line:

  • rel="preload" tells the browser this is a high-priority fetch to start immediately, ahead of the parser discovering the @font-face rule in the stylesheet.
  • as="font" sets the correct Accept header and — critically — tells the browser which "destination" this request is for, so it can match the fetch mode the eventual font consumer will use.
  • type="font/woff2" lets browsers that don't support WOFF2 skip the preload entirely, avoiding a wasted fetch for a format they can't use.
  • crossorigin (written bare, which is equivalent to crossorigin="anonymous") forces the preload's fetch mode to CORS-anonymous with no credentials — matching what the @font-face engine will request later. Without it, Chrome and Firefox both log a console warning along the lines of "was preloaded using link preload but not used within a few seconds," which is the tell-tale sign of this exact bug.
  • The stylesheet link comes after the preload so the preload's fetchpriority isn't undercut by the CSSOM-blocking stylesheet request; see fetchpriority for Font Requests for tuning request priority further.

Same-origin fonts need crossorigin just as much as fonts served from a CDN. Developers frequently assume the attribute is only for cross-origin resources — it is named after CORS, after all — but the font destination is the exception: the browser's fetch-mode matching for as="font" ignores same-origin/cross-origin distinctions and always requires the anonymous CORS mode.

Font bytes transferred: with vs without fix Bar chart comparing total font bytes transferred with and without the crossorigin attribute on a font preload link. Font bytes transferred: with vs without fix No crossorigin (2 fetches) 240KB With crossorigin (1 fetch) 120KB KB transferred
Missing crossorigin doubles the font transfer for the same file.

Defensive variant: preloading from a CDN or subdomain

When fonts are served from a separate origin — a dedicated font CDN, a static. subdomain, or a third-party host — the crossorigin requirement compounds with actual CORS policy. The CDN must also return an Access-Control-Allow-Origin header that matches your page's origin (see Serving Fonts from a CDN with Correct CORS for the header configuration), or the preloaded bytes will be discarded as an opaque, unusable response:

<link rel="preconnect" href="https://static.example.com" crossorigin>
<link
  rel="preload"
  href="https://static.example.com/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
  fetchpriority="high"
>
# static.example.com font response headers
add_header Access-Control-Allow-Origin "https://www.example.com" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;

The preconnect hint also carries crossorigin because the TCP/TLS handshake it opens must be usable for the anonymous-mode font request that follows — a preconnect without crossorigin opens a separate connection that the CORS-mode font fetch can't reuse, defeating the purpose of preconnecting at all. If your CDN can't be configured with a permissive Access-Control-Allow-Origin, self-hosting the font file removes the CORS layer entirely, at the cost of losing any shared-cache benefit across sites using the same public CDN.

Preload-to-render request flow Numbered steps showing how a preload link is matched or mismatched against the later font-face fetch based on the crossorigin attribute. Preload-to-render request flow 1 Parser hits preload link as=font 2 Browser starts anonymous fetch needs crossorigin 3 CSSOM builds @font-face rule always CORS-anonymous 4 Font engine requests glyph data matches cache or refetches 5 Text paints 1 fetch or 2
The preload's fetch mode must match the font engine's fetch mode to be reused.

Verification

Open Chrome DevTools → Network, filter by Font, and reload the page with cache disabled. Before the fix, you'll see two rows for the same font URL: one initiated by "Preload" with a status like 200 and a small transfer size (often served from a fast local cache on repeat inspection), followed a moment later by a second 200 row initiated by "Other" or "CSS" with the full transfer size again. After adding crossorigin, only one row remains, and its Initiator column reads Preload while its Time reflects the fetch starting at document-head parse time rather than at CSSOM-construction time.

You can also confirm programmatically with the Resource Timing API:

const fontEntries = performance
  .getEntriesByType("resource")
  .filter((e) => e.name.endsWith(".woff2"));

if (fontEntries.length > 1) {
  console.warn(
    `Expected 1 font request, got ${fontEntries.length} — check crossorigin on the preload link.`
  );
}

Run this after window.load fires; a length greater than one for a single font file is a reliable signal that a preload/fetch mismatch is still duplicating the download. Lighthouse's "Preload key requests" and "Avoid chaining critical requests" audits will also flag the wasted second fetch, and the DevTools console prints the standard "preloaded... but not used within a few seconds" warning whenever the mismatch persists.

For a more precise check, inspect the initiatorType and transferSize fields on each matching entry rather than just the count:

performance
  .getEntriesByType("resource")
  .filter((e) => e.name.endsWith(".woff2"))
  .forEach((e) =>
    console.log(e.name, e.initiatorType, e.transferSize, e.duration.toFixed(1))
  );

A correctly configured preload shows exactly one entry with initiatorType: "link" and a transferSize matching the file's real compressed weight. If you instead see two entries — one "link" and a second "css" — with roughly equal transferSize values, the mismatch is still present and the fix hasn't taken effect, possibly because a service worker, a CDN redirect, or a stale HTML cache is serving the old markup without crossorigin.

crossorigin attribute outcomes Comparison table of preload link configurations and whether each results in a single font fetch, a duplicate fetch, or a blocked CORS response. crossorigin attribute outcomes Same-origin font CDN font w/ ACAO CDN font, no ACAO No crossorigin Double fetch Double fetch Blocked crossorigin (anonymous) Single fetch Single fetch Blocked crossorigin=use-credentia… Double fetch Blocked Blocked
Only the bare anonymous form avoids a duplicate font fetch.

Common Pitfalls

  • Omitting crossorigin on same-origin fonts. The most common version of this bug — developers preload a same-origin font, see no immediate error, and don't notice the duplicate fetch until they inspect the waterfall. Always add crossorigin to every font preload, same-origin or not.
  • Adding crossorigin="use-credentials" instead of anonymous. Fonts must be fetched without credentials; using use-credentials (or expecting cookies to ride along) causes the CORS check to fail unless the server explicitly allows credentialed requests with Access-Control-Allow-Credentials, which most font CDNs do not set. Use the bare crossorigin (anonymous) form.
  • Preloading without a matching preconnect crossorigin for cross-origin fonts. The connection warm-up needs the same crossorigin mode as the font fetch it's meant to accelerate, or the browser opens a second connection and the preconnect's benefit is lost.
  • Mismatched as value. Setting as="fetch" or omitting as entirely means the browser can't determine the correct fetch-mode/destination pairing, so it either ignores the preload's priority boost or, again, ends up fetching the resource twice under different destinations.
  • Missing type attribute causing wasted preloads in older engines. Without type="font/woff2", some browser versions preload a format they'll never use for a client that doesn't support WOFF2, wasting bandwidth on a request that gets discarded once the @font-face src list is evaluated for a supported format.

Frequently Asked Questions

Does crossorigin matter if the font is served from the exact same origin as the HTML document? Yes. The CSS Fonts spec's fetch algorithm forces every font request into anonymous CORS mode regardless of origin, so the preload hint must match that mode with crossorigin even when there is no actual cross-origin boundary being crossed. This is the single most surprising part of the preload-font interaction and the reason the double-fetch bug shows up so often on self-hosted fonts.

What happens if I forget crossorigin — does the page break, or just get slower? The page still renders correctly; nothing breaks visually. The cost is purely performance: the font downloads twice, doubling its contribution to total transfer bytes and delaying whichever fetch actually gets used to rasterize text, which can push back First Contentful Paint and Largest Contentful Paint on font-dependent hero text.

Do I need crossorigin on rel="prefetch" hints for fonts I'll need on the next page navigation? Yes, for the same reason — prefetch and preload share the same destination-based fetch-mode matching. If you prefetch a font for a likely next page (see Preload vs Prefetch vs Preconnect for Fonts), keep as="font" and crossorigin on that hint too, or the prefetched bytes won't be reused when the next page's @font-face rule requests the file.

Does this attribute affect font-display behavior at all? No — crossorigin only controls which cached response a fetch matches; it has no effect on the block/swap/fallback timing controlled by font-display values. A missing crossorigin just means the font arrives later (after two fetches instead of one), which indirectly makes whatever font-display period you've configured feel longer in practice.

Related