# Invisible text, then a swap: the web font flash

> Text that is missing for a second and text that changes typeface in front of you look like two bugs. They are **one timer**, set by one descriptor, and every page with a webfont has chosen a setting for it - most of them by not choosing.

Published 2026-11-10. Canonical: https://loupekit.com/blog/web-font-flash/

A webfont is requested late - after the HTML, after the stylesheet, after layout finds text that wants it - so on a first visit there is always a moment when the browser has words to draw and no font to draw them in. It has two options, and the two old names describe them:

- **FOIT**: Flash of invisible text. The browser lays the text out and paints nothing, holding the space until the font arrives. The page looks finished except that the words are missing.
- **FOUT**: Flash of unstyled text. The browser paints with a fallback straight away and swaps in the webfont when it lands. Readable at once, and visibly different a moment later.

Neither is a malfunction. Both are the browser following an instruction, and the instruction is `font-display`.

## The three periods

The CSS Fonts specification divides the time after a face is first needed into three periods, and every `font-display` value is a different length for each:

1. **Block period.** The text is laid out with a fallback's metrics but painted invisibly. If the font arrives now it is used, and nobody saw anything else.
2. **Swap period.** The fallback is painted. If the font arrives now, the text is redrawn in it - the visible swap.
3. **Failure period.** The browser stops waiting. Whatever is painted stays painted, even if the file turns up later.

- **auto**: The browser decides. In current engines it behaves like `block`, which is why a page that never set the descriptor still hides its text.
- **block**: A short block period - the specification suggests three seconds - and then an unlimited swap. Invisible text first, a late swap if the network is slow.
- **swap**: A block period close to zero, then an unlimited swap. Text is visible at once, and the webfont replaces it whenever it arrives, however late.
- **fallback**: A tiny block period and a short swap period of about three seconds. If the font misses that window the fallback stays for this page view.
- **optional**: A tiny block period and no swap at all. The font is used only if it is ready almost immediately; otherwise the page keeps the fallback, and the file finishes downloading for the next page to use from cache.

> Read that way, the worst case of `block` is **both** flashes in sequence: three seconds of nothing, then the fallback, then a swap when the file finally arrives. Invisible text does not buy you a swap-free page on a slow connection.

## Choosing a value per font, not per site

Most stylesheets set one value on every `@font-face` and move on. The right value depends on what the face is doing on the page:

- **Body text**: `swap` or `optional`. Somebody arrived to read, and three seconds of blank paragraphs is the worst available outcome. `optional` trades the brand face on a slow first visit for a page that never reflows.
- **Headings and display type**: `swap` when the face is the identity, `fallback` when a late swap of a large headline would be more disruptive than not swapping. Large type is where a reflow is most visible.
- **Icon fonts**: `block`. An icon font drawn in a fallback shows its raw characters - a private-use code point as an empty box, or a ligature name like `arrow_forward` spelled out in full. Invisible for a moment is better than wrong.

*two faces from one family, with two different timers*

```css
@font-face {
  font-family: 'Brand';
  src: url('/fonts/brand-text.woff2') format('woff2');
  font-weight: 400;
  font-display: optional;
}

@font-face {
  font-family: 'Brand';
  src: url('/fonts/brand-display.woff2') format('woff2');
  font-weight: 800;
  font-display: swap;
}
```

For fonts loaded through a hosted stylesheet the descriptor is set by whoever wrote that stylesheet. Google Fonts takes it as a parameter - `&display=swap` on the CSS URL - and a link copied without one inherits the provider's default.

## Flashes that are not about the first visit

A flash on a cold cache is expected. A flash on the **second** page of a visit, or on every navigation, means something is putting a delay back in front of a font the browser should already have:

- **No cache lifetime on the font files.** Without a long `Cache-Control` max-age a file may be revalidated on navigation, which is a round trip before it can be used. Font URLs that carry a content hash can be cached for a year.
- **The `@font-face` rule arrives late.** CSS-in-JS libraries and lazily loaded component styles can inject the declaration only after the page has hydrated, so the browser does not know the font exists until after first paint - on every load.
- **A chain of stylesheets.** An `@import` inside a stylesheet that is itself linked from another origin is three requests in series before the font file is even named.
- **A different `unicode-range` subset.** A page with an accented name or a different script needs a subset the previous page never requested, and that subset flashes on its own.

The cures for the first-visit case - a `preload` hint for the one face above the fold, and a fallback face with `size-adjust` and metric overrides so the swap does not move anything - are set out in an earlier note on declared and rendered fonts. They are complementary: `font-display` decides **when** the swap happens, metric overrides decide **whether anybody notices**.

## Watching it happen

The flash is easy to miss on a fast machine with a warm cache, which is the machine most pages are checked on. Disable the cache, throttle the connection to a slow mobile profile, and reload. Then compare when each font finished against when text was first painted:

*fonts that arrived after the first paint*

```js
const fcp = performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? 0;

performance.getEntriesByType('resource')
  .filter((r) => /\.(woff2?|ttf|otf)(\?|$)/.test(r.name))
  .map((r) => ({
    font: r.name.split('/').pop(),
    done: Math.round(r.responseEnd),
    afterPaint: r.responseEnd > fcp,
  }));

// afterPaint: true means the first paint happened without
// that face - as invisible text or as a fallback, depending
// on its font-display.
```

A font that finished after the first paint and is declared with `block` or `auto` was invisible text for that long. One declared with `swap` was a fallback, and a swap followed. If the swap moved anything, it will appear as a layout shift on the text block that reflowed.

**Font faces** lists every `@font-face` with what it declares, so a missing or inconsistent `font-display` is one column to read rather than a stylesheet to search. **Font loading** compares what was declared with what is being painted, and **Shifts** attributes the reflow, if there was one, to the element that moved.
