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-familylist, 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. loadwaited 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
domcontentloadedto save time. Withfont-display: block, this captures empty headings. - Calling
document.fonts.readybefore the text exists. The promise resolves at once, because no font has been requested yet. - Testing with
document.fonts.check()alone. It returnstruewhen 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.
Related terms
To capture pages with web fonts without running a browser yourself, create a free account and start with the load default.