A self-hosted web font declared with font-display: swap only starts downloading about two seconds into the page load, so visitors read the fallback and then watch the text restyle. Why does the font request start so late, and how do you make it start earlier?
answer
- nothing in the HTML points to the file
- CSS parsed, then an element must match
- preload scanner cannot read stylesheets
- the attribute you forget causes two downloads
- preload only what is above the fold
basics
~20 sFonts are discovered late because the browser requests one only after downloading and parsing the stylesheet and finding an element that uses the family. A rel=preload link with as="font", the right type and crossorigin starts the fetch alongside the CSS instead.
solid answer
~60 sThe font file is referenced only from inside CSS, so the browser cannot know it exists until the stylesheet has been fetched and parsed, and it deliberately does not fetch a declared face until style resolution finds an element that actually uses that family. That is a good default — it stops unused faces downloading — but it means the font sits at the end of a chain: HTML, then CSS, then style and layout, then the font. Any extra hop, such as an `@import` inside the stylesheet, adds another round trip. The fix is to give the preload scanner something to see in the HTML: `<link rel="preload" as="font" type="font/woff2" href="/fonts/brand.woff2" crossorigin>` in the head, which starts the fetch in parallel with the CSS. `crossorigin` is mandatory even same-origin, because font fetches use CORS mode and a mismatched preload is not reused — you would download the file twice. Preload only the one or two faces visible above the fold; each one competes for bandwidth with the CSS and hero image.
code
html · 11 lines<head>
<link rel="preload" as="font" type="font/woff2"
href="/fonts/brand-400.woff2" crossorigin>
<link rel="stylesheet" href="/styles/main.css">
</head>
<!-- main.css:
@font-face {
font-family: "Brand";
src: url("/fonts/brand-400.woff2") format("woff2");
font-display: swap;
} -->go deeper
Know that the browser only learns about a font after it downloads the CSS, and that a rel=preload link in the head can start the download sooner.
Be ready to walk the chain HTML to CSS to style match to font request, and to name every attribute a font preload needs and why the URL must match exactly.
Show the diagnosis first: compare stylesheet and font request start times, distinguish late discovery from slow transfer, and justify preloading only above-the-fold faces given what the fetch competes with.
Own it as a system rule — a small budgeted set of preloaded faces, a check that stops new ones appearing unreviewed, and field data confirming the swap actually stopped happening for most users.
## The discovery chain A font file is one of the most deeply buried resources on a page. Trace what has to happen before the browser can even issue the request: 1. The HTML arrives and is parsed. 2. A `<link rel="stylesheet">` is found, requested, and downloaded. 3. The CSS is parsed, producing an `@font-face` rule that names a family and a `src` URL. 4. Style resolution runs and finds at least one element whose computed `font-family` resolves to that face. 5. *Only then* does the browser request the font file. Step 4 is deliberate. A stylesheet may declare a dozen faces — several weights, an italic set, a display face used on one page — and downloading all of them on every page would be wasteful. Lazily fetching only the faces actually matched is the right default. The side effect is that the font's request time is the sum of every earlier hop, and any additional indirection makes it worse: an `@import` inside the stylesheet inserts a second serial round trip before the `@font-face` rule is even parsed, and render-blocking CSS delivered slowly pushes everything back. This is also why the `font-display` value cannot rescue the situation. The display timeline starts when the browser attempts to use the face, so a request that begins two seconds in gets its swap window starting at two seconds. `swap` chose *which* bad frame the reader sees; it did nothing about *when*. ## Confirming the diagnosis Before changing anything, look at a throttled, cache-disabled load and compare start times: when did the stylesheet request start and finish, and when did the font request start? A font request that begins roughly when the CSS finishes confirms plain discovery latency. A font request that begins much later than that points elsewhere — the text may be inside content injected after hydration, or the family may only be matched by a rule that applies to a lazily rendered element. ## Preloading the face The preload scanner reads the raw HTML ahead of the parser and starts fetches for things it can see. It cannot see inside CSS, so you tell it explicitly: ```html <link rel="preload" as="font" type="font/woff2" href="/fonts/brand-400.woff2" crossorigin> ``` Four parts, all load-bearing: - `as="font"` sets the correct request destination and priority, and lets the browser match the preload to the later CSS-initiated request. - `type="font/woff2"` lets a browser without woff2 support skip the download entirely. - `href` must be byte-identical to the URL in the `src` descriptor, or the preload and the real request are two different resources and you fetch the file twice. - `crossorigin` is required even for a same-origin font. Font fetches are made in CORS mode regardless of origin; a preload issued without the attribute has different credentials mode and will not be reused. The symptom is unmistakable in the network panel — the same file downloaded twice — and browsers usually log a console warning that the preloaded resource was not used. ## The cost, and how much to preload A preload is not free. It is a high-priority fetch that competes with the render-blocking CSS and, on a hero-image page, with the image that determines the largest contentful paint. Preloading five weights guarantees you have slowed something else down. The discipline is: preload only the faces actually used in the first viewport — typically one body weight, sometimes a heading weight — and let everything else be discovered normally. Preloading a face the page never uses is pure waste: the bytes are spent and the file is never applied. ## Other levers on the same problem Cutting the chain shorter helps even without preload: inline the `@font-face` block into the document rather than an `@import`ed file, and make sure the stylesheet itself is not delayed. Reducing the number of distinct faces means fewer serial discoveries. And if the font is served from another origin, the connection setup itself is part of the delay — an early connection hint saves the DNS, TCP and TLS round trips before the request is even issued. ## What good looks like After the fix, the font request should start at roughly the same moment as the stylesheet request rather than after it. Then the display policy finally does what it was chosen to do: the swap window opens early enough that the font usually wins it, and most users never see the restyle at all.
- Why does a preload for a same-origin font still need the crossorigin attribute?Because font files are always fetched in CORS mode, whatever their origin. A preload without `crossorigin` is issued in a different mode, so the browser cannot match it to the request the CSS makes later and fetches the file a second time. You see the duplicate in the network panel and usually a console warning too.
- How many font faces should a page preload?Usually one or two — the faces that render in the first viewport. Preload is a high-priority fetch competing with the render-blocking CSS and the largest image, so each extra one buys font speed by delaying something else. Faces used below the fold are better left to normal discovery.
- Why doesn't switching from swap to block or optional fix this symptom?Because the display timeline starts when the font request starts. Changing the descriptor changes what is painted during the wait, not the length of the chain that caused it. With a two-second discovery, `block` gives two seconds of fallback then blank-then-swap behaviour late, and `optional` simply guarantees the fallback for the whole load.
- How would you confirm the font is genuinely discovered late rather than slow to transfer?Compare request start times on a throttled, cache-disabled load. If the font request begins only after the stylesheet response completes, it is discovery latency. If it starts early but takes a long time, it is transfer — a different problem with different fixes, and preload will not help much.
saying these in an interview costs you the question
- Says a preload makes font-display: swap unnecessary
- Omits crossorigin because the font is same-origin
- Preloads every weight and italic of the family
- Thinks the preload scanner reads @font-face rules
- Blames the font's file size before checking request start time