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).

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 A horizontal timeline showing the browser font-display: swap state machine. The block period runs from 0 to 100ms. Without optimisation the font arrives at ~900ms, producing a long swap event. With preconnect + high-priority preload the font arrives at ~180ms, collapsing the swap window to 80ms. Block 100ms Swap period — fallback font visible Font arrives ~900ms (no preload) Font arrives ~180ms (preconnect + preload) 80ms swap 0 100ms 300ms 600ms 900ms Block period (100ms invisible text) Swap period — fallback shown, waiting for font Optimised swap window (preconnect + high-priority preload) 800ms of avoidable queue delay (low-priority + cold connection)

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.

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.
  • [ ] 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 values using the Malte Ubl Size Adjust Calculator or Chrome DevTools’s font rendering debug panel. 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. “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 ~20 ms after the block period ends rather than 750 ms after it. The CLS drop is almost entirely attributable to metric alignment, not to the reduced swap window — a 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