Reducing Font Swap Duration with font-display: swap

font-display: swap causes visible text to flash from fallback to custom typeface whenever the font file arrives after the browser’s 100 ms block window — the swap event extends for up to 3 seconds and is the primary driver of typographic layout instability on font-heavy pages.

Root Cause: How the Browser Font Scheduler Produces a Swap

The browser’s font loading pipeline has three phases defined in the CSS Fonts Level 4 spec: the block period, the swap period, and the failure period. Under font-display: swap, the block period is exactly 100 ms and the swap period is infinite (the spec uses “forever” — browsers implement this as approximately 3 seconds before the failure period begins).

During the block period, text using the custom font is rendered invisibly — no fallback is shown. If the font file has not been received within 100 ms, the browser immediately switches to the fallback font and enters the swap period. When the font finally arrives, the browser re-renders affected text using the custom typeface. This re-render is the visible “flash” — the Flash of Unstyled Text (FOUT).

Read as a state machine, the three periods and the two clocks that move between them look like this — with the arrival times measured on the test page described further down.

State machine of the three font-display: swap periods and the outcome each one produces Three scheduler states in a row. The block period runs from 0 to 100 milliseconds with text painted invisible; if no bytes have arrived the face moves to the swap period, where the fallback is on screen until roughly three seconds; after that the failure period makes the fallback permanent. Below each state is its outcome: no flash at all if the bytes arrive during block, an 80 millisecond swap event if they arrive at 180 milliseconds, and a permanent fallback if they never arrive. The three font-display: swap periods for one @font-face, with this page's measured timings 100 ms, no bytes yet ~3 s, still no bytes Block period 0 to 100 ms text painted invisible Swap period 100 ms to about 3 s fallback face on screen Failure period after about 3 s fallback becomes final bytes arrive in time bytes arrive at 180 ms no bytes at all No FOUT at all custom face paints first swap event never fires 80 ms swap event one repaint, CLS 0.02 once fallback metrics are aligned Fallback is permanent for this page load; the face applies on next navigation Arrival times measured on a 20 Mbps, 80 ms RTT link with a 48 KB variable WOFF2 — see the before and after table below.

The length of the swap event is determined by how long after the 100 ms mark the font response completes. That duration is a direct function of network conditions and, critically, the fetch priority assigned to the font request. When a font fetch is queued at Low or Idle priority — which is the browser default for fonts discovered during CSS parsing — it waits behind render-blocking scripts, stylesheets, and high-priority images. Every millisecond of queue time extends the swap window identically.

Compounding the queue delay, fonts are cross-origin by definition when served from a CDN or font service. Cross-origin requests require the browser to perform a CORS handshake. If the origin connection is not already established, a TCP + TLS negotiation adds 150–300 ms before a single byte of font data is transferred. The preconnect resource hint exists specifically to eliminate this warm-up cost, but it must be placed correctly in the document to be effective.

Finally, when a <link rel="preload" as="font"> tag is present but missing the crossorigin="anonymous" attribute, the browser treats the preloaded response and the CSS-triggered fetch as two separate cache entries. The font is fetched twice: the preload warms one cache slot, CSS triggers a fresh fetch into a different slot, and the second fetch starts from scratch — producing a swap duration longer than if no preload hint had been present at all.

The Font Swap Timeline

The diagram below maps the browser’s font scheduling states onto a real network timeline, showing where each optimization intervention reduces the swap window.

Font swap timeline showing the 100 ms block period, the swap window, and the 770 ms that preconnect plus a high-priority preload remove A single horizontal timeline of one font request. The block period occupies the first 100 milliseconds with text painted invisible. Without hints the font arrives at 950 milliseconds, so the swap window lasts 850 milliseconds. With a preconnect and a high-priority preload the font arrives at 180 milliseconds and only the first 80 milliseconds of the swap window remain; the other 770 milliseconds were queueing and connection setup. One font request on a 20 Mbps, 80 ms RTT link — where the 850 ms swap window goes preconnect + high-priority preload font arrives at 180 ms default priority, cold connection font arrives at 950 ms Block 100 ms 80 ms avoidable swap — 770 ms fallback face on screen while the font waits in the queue 0 100 180 300 600 950 ms Block period — 100 ms of invisible text before any fallback paints Swap that survives the fix — 80 ms, one repaint at 180 ms Swap the fix removes — 770 ms of queueing, DNS, TCP and TLS setup Baseline swap window 850 ms (100 ms to 950 ms); optimised 80 ms (100 ms to 180 ms). 48 KB variable WOFF2.

Minimal Reproduction

This is the smallest HTML fragment that demonstrates both the problem and the fix side-by-side. The type and crossorigin attributes are mandatory — omitting either causes a silent double-fetch or a no-op preload.

<!-- BROKEN: font discovered late during CSS parse, cold connection, low priority -->
<link rel="stylesheet" href="/fonts/inter.css">

<!-- FIXED: warm the connection first, then preload at high priority before CSS -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- fetchpriority="high" promotes this above images and async scripts in the queue -->
<link
  rel="preload"
  as="font"
  href="https://fonts.gstatic.com/s/inter/v13/UcCo3FwrK3iLTcviYwY.woff2"
  type="font/woff2"
  crossorigin="anonymous"
>
<!-- Inline the @font-face to bypass render-blocking external CSS parse -->
<style>
  @font-face {
    font-family: 'Inter';
    src: url('https://fonts.gstatic.com/s/inter/v13/UcCo3FwrK3iLTcviYwY.woff2') format('woff2');
    font-display: swap; /* 100ms block, then show fallback while font loads */
  }
  /* Align fallback metrics to Inter to suppress layout shift during swap */
  @font-face {
    font-family: 'Inter-fallback';
    src: local('Arial');
    ascent-override: 90.2%;
    descent-override: 22.48%;
    line-gap-override: 0%;
    size-adjust: 107.4%;
  }
  body { font-family: 'Inter', 'Inter-fallback', sans-serif; }
</style>

The ascent-override, descent-override, and size-adjust descriptors in the fallback @font-face are the metric-alignment technique that suppresses CLS during the swap event. Without them, even a short 80 ms swap window can produce a CLS score above 0.1 on text-heavy pages.

The crossorigin attribute is the easiest of these to drop, and its failure mode is the hardest to read in DevTools: two identical rows for the same URL, with nothing to say why. Traced message by message, the wasted round trip is obvious.

Sequence diagram of a preload without crossorigin, showing the cache miss that forces a second fetch of the same font file Three lifelines: the preload scanner, the browser cache, and the font origin. The preload fires at 0 milliseconds without a crossorigin attribute, so the response lands in a non-CORS cache slot at 210 milliseconds. When CSS parsing reaches the font-face rule at 340 milliseconds the lookup misses, a second CORS request for the same 48 kilobyte file is issued, and the face only becomes usable at 950 milliseconds — an 850 millisecond swap event. A preload missing crossorigin: the same 48 KB WOFF2, fetched twice Preload scanner Browser cache fonts.gstatic.com 0 ms preload fires, no crossorigin attr 5 ms GET woff2, no Origin header 210 ms 200 OK, stored in a no-CORS slot 340 ms CSS parse reaches the font-face 341 ms miss, the slot is not CORS-clean 342 ms second GET, Origin header set 950 ms 200 OK, the same 48 KB again 950 ms face usable, 850 ms swap event Add crossorigin=anonymous to the preload and the last four messages never happen: the CSS lookup hits the slot the preload already filled, and the face is usable at 180 ms.

Deterministic Fix Protocol

Work through these steps in order. Each is independently verifiable in Chrome DevTools before proceeding to the next.

  • [ ] 1. Establish connection early. Add <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> as the first child of <head>. Verify in the Network panel: the font origin should show (preconnect) in the Initiator column and the connection’s Stalled time should be near zero.
  • [ ] 2. Preload the font at high priority. Add <link rel="preload" as="font" href="…" type="font/woff2" crossorigin="anonymous"> immediately after the preconnect. Confirm Priority column shows Highest. If it shows Low, the preload is placed after a render-blocking stylesheet — move it before. If the console also logs a “preloaded but not used” warning for the font, the preload URL and the URL the CSS requests are not byte-identical — that warning has its own short checklist.
  • [ ] 3. Inline the @font-face declaration. Move the font-face rule into a <style> block in <head> rather than an external stylesheet. This eliminates the CSS parse → font discover latency chain. Verify: in the Network panel, the font request’s Initiator should be <link> (the preload), not the external CSS file.
  • [ ] 4. Add crossorigin="anonymous" to both tags. Open the Network panel, right-click the font request, and select “Copy as cURL”. If the --header 'Origin: …' flag is present, the CORS fetch is working. If two font requests appear with identical URLs, the crossorigin attribute is missing on one of them.
  • [ ] 5. Align fallback font metrics. Add size-adjust, ascent-override, and descent-override to the fallback @font-face rule. Calibrate the values from the two faces’ own metrics: divide the custom face’s unitsPerEm-normalised ascent by the fallback’s to get ascent-override, and its average character width by the fallback’s to get size-adjust. Chrome DevTools’ Rendering panel and the document.fonts API both expose enough to verify the result in the browser. Run a WebPageTest trace and confirm CLS attributable to font swap is below 0.05.
  • [ ] 6. Set Cache-Control: public, max-age=31536000, immutable on the font file. Confirm in the Network panel on second load: the font should be served from disk cache ((disk cache) in the Size column) with zero transfer time.
  • [ ] 7. Audit for priority inversion. In the Performance panel, record a load and search for Layout events that immediately follow a Paint event during font load. If the Layout event’s self-time exceeds 4 ms, the metric alignment is insufficient or another resource is delaying the font further.

Before/After Metrics

Measured on a simulated 4G connection (20 Mbps down, 80 ms RTT) against a page using a 48 KB variable font WOFF2 subset to the Latin range. “Optimised” applies steps 1–7 above.

Metric Baseline Optimised Change
Font queue delay 420 ms 0 ms −420 ms
Swap window duration 850 ms 80 ms −91%
Double-fetch requests 2 0 −2
CLS (font swap contribution) 0.18 0.02 −89%
TTFB variance (p95) ±120 ms ±28 ms −77%
Lighthouse Performance score 68 91 +23 pts

The swap window shrinks from 850 ms to ~80 ms because the preload fires before any stylesheet is parsed, the warm connection eliminates TCP+TLS setup, and the font arrives ~80 ms after the block period ends rather than 850 ms after it. Of that 770 ms saving, roughly 420 ms is queue time the fetchpriority promotion recovers, around 250 ms is the DNS, TCP and TLS handshake the preconnect absorbs, and the remainder is the CSS parse latency the inlined @font-face removes. The CLS drop is almost entirely attributable to metric alignment, not to the reduced swap window — an 80 ms swap without alignment still scores ~0.12.

Framework Notes

Frameworks abstract font loading in ways that can silently reintroduce queue delay.

Next.js: next/font injects preload hints and CSS variables automatically. However, verify that the generated <link rel="preload"> appears before the main CSS bundle in the rendered <head>. If the CSS bundle is inlined above the font preload (a build-order artefact), move next/font declarations to a layout component that is evaluated before global styles are applied.

Vite: Vite’s dev server strips resource hints from index.html during HMR. Add preload hints conditionally for production builds using vite-plugin-html or a custom transformIndexHtml hook. Confirm the built dist/index.html contains the preload before deploying.

Webpack: Use HtmlWebpackPlugin with a custom template to inject font preloads before the CSS bundle tag. The default mini-css-extract-plugin output order may place font-face stylesheets ahead of explicit preload tags in the emitted HTML.

FAQ

Does font-display: swap hurt CLS even when fallback metrics are aligned?

Yes, metric alignment reduces but rarely eliminates CLS. Even a 1–2% difference in x-height produces a measurable layout shift. Use size-adjust, ascent-override, and descent-override together to reach CLS below 0.05 for the swap event alone. Measure with WebPageTest’s filmstrip view to isolate the exact frame where the shift occurs.

Can I use font-display: optional instead to avoid the swap entirely?

font-display: optional gives the browser a ~100 ms block period, then abandons the download for that page load if the font has not arrived. It eliminates the swap but means the custom font may never display on first load over slow connections. It is the right choice only when the fallback and custom font are visually near-identical, or when you pair it with a preload hint that reliably delivers the font within 100 ms on your target connection.

Why does the font preload sometimes not reduce swap duration in Chrome?

The most common cause is a missing or mismatched crossorigin attribute. A preload hint without crossorigin="anonymous" is treated as a different cache entry from the CORS font fetch triggered by CSS, so the browser fetches the font twice and the preload warms the wrong cache slot. The second root cause is placement: a preload injected by JavaScript after the parser reaches the <body> is discovered too late to beat the CSS-triggered fetch. Preloads must be in the <head> as static HTML to be effective.


Related