ScreenshotNeo

BlogHow-to

How to Handle Font Loading for Consistent Cypress Screenshots

Wait for used fonts, app readiness, and a fixed rendering environment so Cypress screenshots avoid fallback-font and layout flake.

By the ScreenshotNeo team1 October 20267 min read

Direct answer: Load the same font styles as production, verify every font request succeeds, wait for the application to reach a known ready state, then await document.fonts.ready immediately before cy.screenshot(). For a font injected after the first render, also await a targeted document.fonts.load() call or an application-level “fonts ready” signal. Keep the browser, operating-system image, viewport, device scale, installed fonts, test data, and animation state fixed between baseline and comparison runs.

Cypress waits for the page-load event and the external resources in the loading phase when cy.visit() resolves, but that does not cover every font-loading pattern. Fonts can be injected later, lazy-loaded, or introduced by application code. The CSS Font Loading API’s document.fonts.ready promise is the key synchronization point for fonts used by the current layout (MDN reference).

1. A reliable Cypress screenshot sequence

describe('stable screenshot', () => {
  it('captures the page after data, fonts, and layout are ready', () => {
    cy.visit('/dashboard')

    // Replace this with an assertion that represents your real ready state.
    cy.get('[data-cy="page-ready"]').should('be.visible')

    cy.document().then((doc) => {
      return cy.wrap(doc.fonts.ready)
    })

    cy.screenshot('dashboard-with-fonts')
  })
})

The assertion comes before the font wait because it proves that the application has rendered the intended content. The font wait then lets the browser finish loading and laying out the used faces before the pixels are captured.

Target a face that loads dynamically

cy.document().then((doc) => {
  return cy.wrap(doc.fonts.load('400 16px "Brand Sans"'))
})

cy.get('[data-cy="page-ready"]').should('be.visible')
cy.screenshot('brand-sans-loaded')

Use the exact CSS family, weight, style, and size that the page uses. If the app adds another face after this wait, expose an application readiness flag and wait for that flag immediately before capture.

2. Make Cypress load the same fonts as production

A wait cannot fix a font that is not available. Component tests must include the same font links or @font-face declarations used by the application. Cypress documents adding external links to the component document or loading local styles through the component support setup (Cypress component styling guide).

External stylesheet

<!-- component-index.html -->
<link rel="preconnect" href="https://fonts.example.com">
<link rel="stylesheet" href="https://fonts.example.com/brand.css">

Local font files

/* src/styles/fonts.css */
@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans-semibold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

With Vite, files in public are served from the site root, so public/fonts/brand-sans-regular.woff2 is requested as /fonts/brand-sans-regular.woff2. With Webpack, import the font or configure a static directory. Confirm the final URL in the browser network panel; a 404, blocked request, incorrect MIME type, or CORS error must be fixed at the server or bundler level.

Component support setup

// cypress/support/component.js
import '../../src/styles/fonts.css'
import '../../src/styles/global.css'

Keep font declarations in the same path and order for component tests, end-to-end tests, and production. Differences in CSS order can select a different face even when the files exist.

3. Why fonts cause screenshot drift

  • Fallback metrics differ: text wraps at another word, changing card heights and page length.
  • Weights are synthesized: a missing 600 file may be rendered as a browser-synthesized bold face.
  • Late injection: a web font added by JavaScript can arrive after the initial document.fonts.ready.
  • Unused declarations stay unloaded: ready concerns fonts used by layout; it does not promise that every declared face has downloaded.
  • Environment differences: browser version, operating-system font rasterization, device scale, and installed system fonts alter pixels.
  • Motion is still active: a font wait does not stop CSS transitions, animated counters, carousels, or video frames.

The CSS Font Loading specification explains the lifecycle and the one-time nature of the ready promise (W3C CSS Font Loading). If your application introduces fonts after that promise fulfills, perform a second targeted load or publish a stronger app-level readiness state.

4. Stabilize the rendering environment

  1. Pin the Cypress browser version and the container or operating-system image.
  2. Use the same viewport for baseline and comparison runs.
  3. Keep device scale factor and headless/headed mode consistent.
  4. Install every system font the application intentionally uses, or bundle the required web fonts.
  5. Use deterministic test data, locale, timezone, and feature flags.
  6. Disable transitions and animations in the visual-test environment.
/* visual-test.css */
*, *::before, *::after {
  animation-duration: 0s !important;
  animation-delay: 0s !important;
  transition-duration: 0s !important;
  transition-delay: 0s !important;
  caret-color: transparent !important;
}

A Cypress command option such as waitForAnimations affects action commands. It does not guarantee that a later screenshot is outside an animation, so disable the animation or assert an application-specific completed state.

5. A reusable custom command

// cypress/support/commands.js
Cypress.Commands.add('screenshotWhenReady', (name, options = {}) => {
  const readySelector = options.readySelector || '[data-cy="page-ready"]'
  const font = options.font

  cy.get(readySelector).should('be.visible')
  cy.document().then((doc) => {
    if (font) return cy.wrap(doc.fonts.load(font))
    return cy.wrap(doc.fonts.ready)
  })
  cy.screenshot(name, options.screenshot)
})
// usage
cy.visit('/reports')
cy.screenshotWhenReady('reports', {
  readySelector: '[data-cy="reports-ready"]',
  font: '600 14px "Brand Sans"',
  screenshot: { capture: 'fullPage' }
})

For pages with several dynamically introduced faces, call document.fonts.load() for each required face, then wait for the page-ready assertion again. Avoid an arbitrary cy.wait(2000): it is slow when the page is fast and still flaky when the page is slow.

6. Troubleshooting

Symptom Likely cause Fix
Times New Roman or another fallback appears Font URL is missing, blocked, or not included in component setup Inspect the network request, fix the path/server/CORS configuration, and load the stylesheet in component-index.html or support code.
Only bold or italic text differs The requested weight/style file is absent and the browser synthesizes it Add the exact @font-face declaration and file, then target it with document.fonts.load().
document.fonts.ready resolves but the next screenshot still changes A font is injected after the first promise or a layout update is still pending Wait for the app-ready signal and call targeted document.fonts.load() immediately before capture.
Local fonts work in production but fail in component tests Bundler static-asset rules differ For Vite, serve from public; for Webpack, import assets or configure a static directory. Verify the final URL.
Text wraps differently across CI and a laptop Different browser, OS, viewport, scale, or installed fonts Pin the complete rendering environment and use one fixed viewport and device scale.
Intermittent differences remain after font waits Animations, async data, clocks, random IDs, or changing content Freeze data and time where needed, assert data readiness, disable motion, and remove nondeterministic values.
Cross-origin font request is blocked Font server lacks an appropriate CORS response Serve the font from the test origin or configure the font host to allow the test origin; do not hide the failure with longer waits.

7. Performance, reliability, and cost

  • Performance: Use WOFF2, preload only critical faces, and avoid waiting for font families that are not visible in the captured state.
  • Reliability: Assert meaningful app state before fonts, retry failed navigation according to your CI policy, and log failed font URLs. A longer sleep increases runtime without proving readiness.
  • Parallel CI: Run workers from the same container image and browser build. Keep baseline generation and comparison on the same viewport and scale.
  • Visual-diff scope: If exact raster equality is not required, use a documented pixel or perceptual threshold, but do not use a threshold to mask missing fonts or layout shifts.
  • Cost: Self-hosted Cypress runs cost your CI resources. A managed capture service can remove browser setup, but compare its font, viewport, and waiting controls with your test requirements.

8. Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. It can wait for a selector, delay, or network idle; set viewport and device presets; load lazy images; apply custom CSS or JavaScript; and capture full pages or a CSS-selected element. Before capture it accepts cookie/consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets, with each step switchable.

Only clean shots are billed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. See the ScreenshotNeo API documentation for all options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is included on every plan. Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge.

9. FAQ

Does cy.visit() guarantee that fonts are ready?

No. It waits for page load and loading-phase external resources, but injected or lazy fonts require an explicit font readiness check.

Should I always use document.fonts.load() instead of document.fonts.ready?

Use ready for fonts used by the current layout. Use targeted load() for a specific face or a font introduced after the first render.

Why does a font declaration exist but the browser still falls back?

The declaration may point to a missing file, the requested weight may not exist, or the request may be blocked by CORS. Network inspection distinguishes these cases.

Can a screenshot service replace Cypress visual regression?

It can handle repeatable page capture and browser setup, while Cypress remains useful for assertions and interaction. Choose based on whether you need test-run control, managed capture, or both.