Web Font Loading: FOIT and FOUT in Screenshots

FOIT and FOUT are the two ways a page looks while its web fonts load. What each one does to a screenshot, measured, and how to wait for fonts in each tool.

By Simon Chiu · Updated

Web font loading is the period between the moment a browser knows it needs a downloadable font and the moment that font is ready to draw; during it, the page shows either invisible text (FOIT, flash of invisible text) or text in a fallback font (FOUT, flash of unstyled text).

A person sees this as a short flicker. A screenshot freezes it. Capture at the wrong moment and you get a hero with no headline, or a headline in Georgia that should be in your brand font. This entry is part of the screenshot glossary. For the CSS switch that chooses between the two behaviours, see font-display.

FOIT and FOUT in plain terms

  • FOIT. The browser reserves the space for the text but paints nothing until the font arrives, or until a timeout runs out (about 3 seconds in Chrome for font-display: block).
  • FOUT. The browser paints the text at once with the next font in the font-family list, then repaints it when the web font arrives. The text often changes width, so the layout moves.

Neither is a bug. Both are a trade-off between "show something now" and "show the right thing". For a screenshot, both states are wrong. You want the third state: the web font is loaded and painted.

The timeline

sequenceDiagram
  participant P as Page
  participant B as Browser
  participant F as Font server
  P->>B: HTML + CSS with @font-face
  B->>F: GET /font.woff2 (only when text needs it)
  Note over B: block period: text invisible (FOIT)
  Note over B: swap period: fallback font shown (FOUT)
  F-->>B: font bytes
  B->>P: repaint with the web font
  Note over B: document.fonts.ready resolves

Two details matter for captures. First, the browser requests a font only when some text on the page needs it, not when it parses @font-face. Second, document.fonts.ready is a promise that resolves when the fonts the page has requested so far are done. It does not know about fonts that nothing has asked for yet.

Measured: what a capture sees at each wait point

We served a test page whose web font (a 48 px heading) arrived 2.5 seconds after the request. We captured it with Puppeteer at domcontentloaded and at load, with font-display: block and with swap. "Ink" counts the dark pixels in the heading area, so 0 means no visible text. The heading is 490 px wide in the web font and 358 px wide in the fallback.

font-display Captured at Time to capture Font status Heading width Ink pixels
block domcontentloaded 16 ms loading 358 px 0 (FOIT)
swap domcontentloaded 9 ms loading 358 px 3,995 (FOUT, fallback font)
block load 2,521 ms loaded 490 px 3,741
swap load 2,514 ms loaded 490 px 3,741
either after document.fonts.ready n/a loaded 490 px 3,741

Method: one run per cell on 2026-10-15, local test server, browser cache disabled, Node 22.18.0, [email protected] with Chrome for Testing 155. The time column only shows that load waited for the font; it is not a benchmark.

What the table shows:

  • With block, an early capture has no text at all, but the layout already reserves its width, measured with the fallback metrics.
  • load waited the full 2.5 seconds for the font, because the heading requested it during the first render.
  • Once the font loads, both modes give the same pixels.

A related trap is not in the table: text that a script inserts after load. Its font request starts late, so load has already fired. In a second test, the heading was added 200 ms after load: at load the heading did not exist yet, and networkidle0 took 4 seconds but returned the right font. Our engineering post on consistent fonts in headless Chrome measures the same problem with pixel diffs.

How to wait for fonts

Puppeteer

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1200, height: 630 });
  await page.goto(url, { waitUntil: 'load' });
  // Fonts requested after load (by late JavaScript) are not covered by 'load'.
  await page.evaluate(() => document.fonts.ready);
  const pending = await page.evaluate(() =>
    [...document.fonts].filter((f) => f.status !== 'loaded').map((f) => `${f.family}:${f.status}`)
  );
  if (pending.length) console.warn('fonts not loaded:', pending); // 'unloaded' = declared but unused
  await page.screenshot({ path: 'fonts.png' });
  console.log('wrote fonts.png');
} finally {
  await browser.close();
}

Playwright

import { chromium } from 'playwright';

const [url = 'https://example.com', selector = 'body'] = process.argv.slice(2);
const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.locator(selector).first().waitFor();   // the text that needs the font exists
  await page.evaluate(() => document.fonts.ready);  // its font requests have finished
  await page.screenshot({ path: 'fonts-pw.png' });
  console.log('wrote fonts-pw.png');
} finally {
  await browser.close();
}

Tested on 2026-10-15 with Node 22.18.0, [email protected] and [email protected] against our delayed-font test page and https://example.com.

RenderScreenshot

The default wait condition is load, which covers fonts that the first render requests. For pages that add text or stylesheets later, wait for network idle, or for an element your page shows once document.fonts.ready has resolved. Both are wait options:

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const image = await client.take(
  TakeOptions.url('https://example.com').preset('og_card').waitFor('networkidle')
);

The same with a ready flag set by your own page: .waitForSelector('[data-fonts-ready]'). This SDK snippet was not run for this page.

Common mistakes

  • Capturing at domcontentloaded to save time. With font-display: block, this captures empty headings.
  • Calling document.fonts.ready before the text exists. The promise resolves at once, because no font has been requested yet.
  • Testing with document.fonts.check() alone. It returns true when no matching face is declared yet.
  • Fixing it with a fixed delay. A 2-second sleep is too short on a slow font server and wastes time on a fast one.
  • Assuming the fallback is harmless. The fallback changes text width, so line breaks and card layouts shift.

To capture pages with web fonts without running a browser yourself, create a free account and start with the load default.

Frequently asked questions

What is the difference between FOIT and FOUT?
FOIT (flash of invisible text) hides text until the web font arrives. FOUT (flash of unstyled text) shows the text in a fallback font first and swaps it later. The CSS font-display descriptor decides which one a page gets.
Does the load event wait for web fonts?
For fonts the page requests during the initial render, yes. In our test, Chrome held the load event until a font that took 2.5 seconds had arrived. Fonts that are requested later, by text or CSS that a script adds after load, are not covered.
Why is the text missing in my headless screenshot?
The capture happened during the font's block period, so the browser drew the text invisibly. Wait for the load event or for document.fonts.ready before you capture.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.