A visual regression test screenshots a page, and on some CI runs the headings come out in a fallback font so the image comparison fails. Why does that happen, and what should the test wait for before it captures?
answer
- text restyles after first paint
- the face arrives asynchronously
- different metrics, different wrapping
- a promise on the font set
- document.fonts.ready, then check()
basics
~20 sWeb fonts load asynchronously, so a screenshot taken too early captures fallback text with different glyph metrics and different line wrapping. Await document.fonts.ready in the page before capturing, and self-host the font files so the test never waits on a third-party CDN.
solid answer
~50 sA web font is fetched lazily: the browser paints text in a fallback face first and re-renders when the real face arrives. If the screenshot lands in that window, glyph widths change, lines wrap differently and every box below moves — so the diff is a whole-layout failure, not a few stray pixels. The fix is to gate the capture on readiness rather than on time: `await document.fonts.ready` inside the page, and optionally assert `document.fonts.check('1rem "Inter"')` so a missing font file fails loudly instead of silently baselining the fallback. Two supporting habits matter as much: serve the fonts from the test's own origin so a blocked or offline CDN cannot change the render, and remember that `document.fonts.ready` only covers faces the browser has already decided to load — open the modal or scroll the lazy section first, then await again.
code
javascript · 10 lines// Run inside the page right before capturing the screenshot.
async function waitForFonts(family) {
await document.fonts.ready;
if (!document.fonts.check(`1rem ${family}`)) {
throw new Error(`Font never loaded: ${family}`);
}
return true;
}
waitForFonts('"Inter"');go deeper
Know that web fonts download asynchronously and that text renders in a fallback face until they arrive. Be able to say you wait on document.fonts.ready before taking the screenshot rather than sleeping.
Explain why a font swap causes a full-page diff rather than local noise — glyph metrics change wrapping and shift everything below — and describe the readiness signal precisely, including that it only covers faces already pending.
Show how you make the failure mode loud: assert the face is actually available so a 404 in CI cannot get baselined as a fallback render, and keep font assets on the test's own origin so runner network policy cannot alter the image.
Frame it as a policy for the whole suite: every asynchronous input to a screenshot needs a defined completion signal, and any capture without one is a future flake. Decide where that gate lives — a shared capture helper — so individual tests cannot reinvent it badly.
## Why the same markup renders two different images A visual regression test only works if identical input produces an identical bitmap. Web fonts break that assumption because font loading is *lazy and asynchronous*. An `@font-face` rule does not download anything by itself; the browser fetches the face only once it lays out text that actually needs it, and until the bytes arrive it renders with the next family in the `font-family` stack (or, under `font-display: block`, renders nothing at all for a short period). That means there are at least three possible renderings of the same page: fallback text, invisible text, and the real face. If your screenshot is taken at an arbitrary moment, you are sampling from that set at random. The failure is also unusually loud. Fallback and web faces have different advance widths, x-heights and line-heights, so text wraps at different words, headings occupy a different number of lines, and every element below shifts vertically. What shows up in the diff viewer is not anti-aliasing noise — it is the bottom two-thirds of the page painted red. ## Gate on readiness, not on time The correct gate is the browser's own font-loading state. `document.fonts` is a `FontFaceSet`, and `document.fonts.ready` is a promise that settles once the pending font-loading and layout work has completed. Awaiting it inside the page is a precise signal in a way that `sleep(500)` never is: a slow CI runner simply waits longer, and a fast one does not waste half a second. ```js // evaluated inside the page, immediately before the capture async function fontsSettled(family) { await document.fonts.ready; if (!document.fonts.check(`1rem ${family}`)) { throw new Error(`font not available: ${family}`); } } ``` The `check()` call is worth adding. Without it, a font file that 404s in CI does not fail the test — it produces a perfectly stable screenshot of the *fallback* face, which then gets approved as the baseline, and the suite quietly stops testing the real typography. ### The caveat people trip over `document.fonts.ready` reports on faces the browser has decided to load *so far*. A face used only by a dialog that has not been opened yet, by a route that has not been visited, or by content still below the fold with `content-visibility` in play, is not pending — so the promise resolves immediately and tells you nothing about it. The rule is: reach the visual state you intend to capture first (open the dialog, scroll the section into view), then await readiness, then capture. ## Take the network out of the loop Even a correct wait is fragile if the font itself comes from somewhere you do not control. In a test environment, fonts should be served from the same fixture server as the rest of the app: a CDN can be blocked by the runner's egress rules, rate-limited, or simply slow, and any of those turns into either a timeout or a fallback render. Self-hosting also removes a class of *cross-run* variation where the CDN serves a different font format (WOFF2 vs TTF) to different clients. If the app legitimately loads fonts late, `<link rel="preload" as="font" type="font/woff2" crossorigin>` starts the request during document parse, which shortens the window in which a fallback can be captured. Note the `crossorigin` attribute is required on font preloads even for same-origin files, because fonts are fetched in CORS mode. ## Fonts are one member of a family of readiness gates The same reasoning applies to anything that arrives after first paint and changes layout: - **Images**: an `<img>` without intrinsic dimensions reserves no space until it decodes. Wait on `img.decode()` or on `complete && naturalWidth > 0`, or give images an `aspect-ratio`/explicit size so late arrival cannot shift the page. - **Lazy content**: anything gated on `IntersectionObserver` must be scrolled into view and settled before capture. - **Skeletons and spinners**: capture only after asserting the loaded state is present, never after a delay. ## What not to do Two anti-fixes recur. The first is raising the pixel tolerance until the failures stop, which does not stabilise anything — it just widens the band in which real regressions also pass. The second is a fixed `sleep`. Both trade a deterministic signal for a probabilistic one, and both are how teams end up with a visual suite nobody trusts. The underlying principle generalises past fonts: a screenshot is a snapshot of an *asynchronous* system, so every asynchronous input must have an explicit completion signal before the shutter opens.
- If the font file 404s in CI, the screenshot is perfectly stable run to run — so why is that still a problem for a visual suite?Because stability is not correctness. A missing font produces a consistent fallback render, which gets approved as the baseline, and from then on the suite happily passes while the real typography is never exercised. Asserting `document.fonts.check()` (or failing the test on font request errors) turns a silent downgrade into a visible failure.
- You await document.fonts.ready and the page still sometimes screenshots in the fallback face. What would you look at?Almost always a face that was not pending at the moment you awaited. The element using it appeared later — a dialog opened after the await, a lazy route mounted, or content below the fold scrolled in. Reach the exact visual state first, then await readiness again immediately before the capture.
- Beyond fonts, what other late-arriving content most often destabilises a page screenshot?Images without reserved space, since they occupy zero height until decoded and then push everything down; lazy sections gated on IntersectionObserver; and anything still showing a skeleton. The general fix is the same shape: assert the loaded state explicitly, or reserve the space up front with width/height or aspect-ratio so late arrival cannot move the layout.
saying these in an interview costs you the question
- Adding a sleep before the screenshot to let fonts load
- Raising the diff threshold until the font failures stop
- Assuming a stable screenshot means the font actually loaded
- Thinking document.fonts.ready covers fonts no element uses yet
- Loading test fonts from a public CDN inside CI