ScreenshotNeo

BlogHow-to

How to Fix Playwright Screenshots with a Missing Web Font in CI

Diagnose Playwright font timeouts and fallback-font screenshots in CI by checking font state, requests, and environment differences.

By the ScreenshotNeo team4 October 20269 min read

A Playwright screenshot that times out while waiting for fonts and one that completes with fallback typography are different failures. First classify the symptom, then wait for the application state that renders the target text, inspect the browser font set and the specific face, and verify the font request and test environment. Increasing a timeout or skipping the font wait does not prove the intended font loaded.

This guide uses Playwright Test with TypeScript. The diagnostic steps also apply when you use Playwright from JavaScript or another supported language. The examples assume the page’s intended face is declared as "Your Family"; replace that with the exact CSS family, weight, style, and size used by the text under test.

1. Classify the failure before changing the test

Read the screenshot call log and inspect the resulting image.

Symptom Likely area to investigate First check
Screenshot call times out at “waiting for fonts to load” Font readiness is stuck or a font face is still loading or errored Log document.fonts.status and each face’s status after the app reaches its final state
Screenshot completes, but glyph shapes, line breaks, or spacing differ The intended face may not have loaded, or the rendering environment differs Check the specific face and font request, then compare browser, OS, and baseline environment
Image comparison fails intermittently Timing, network, parallel work, or environment variation may be involved Make the state deterministic and compare one environment variable at a time

A page reaching document.readyState === 'complete' does not by itself prove a particular web font has loaded. Likewise, a screenshot assertion can produce a stable image with the wrong fallback face.

2. Wait for the page state that uses the font

Navigate to the page and wait for a selector that indicates the target content has been rendered. If client-side code inserts the text later, a document load event alone is too early to diagnose the face used by that content.

import { test, expect } from '@playwright/test';

test('page uses the intended web font', async ({ page }) => {
  await page.goto('https://example.com/article');
  await page.locator('[data-testid="article-title"]').waitFor({ state: 'visible' });

  // Diagnose the font set after the target content exists.
  const fontReport = await page.evaluate(() => ({
    status: document.fonts.status,
    faces: Array.from(document.fonts, face => ({
      family: face.family,
      weight: face.weight,
      style: face.style,
      status: face.status,
    })),
  }));
  console.log('Font report:', fontReport);

  // Ask the browser to load the face matching the target text's CSS.
  const loadedFaces = await page.evaluate(async () => {
    return document.fonts.load('400 16px "Your Family"', 'Article title');
  });
  console.log('Matching faces:', loadedFaces.map(face => ({
    family: face.family,
    weight: face.weight,
    style: face.style,
    status: face.status,
  })));

  // Capture only after the app state and intended face are ready.
  await expect(page.locator('[data-testid="article-title"]')).toHaveScreenshot('article-title.png');
});

The family, weight, style, size, and sample text in document.fonts.load() should match the text you care about. A page may have multiple faces with one family name, and a face may cover only a subset of characters. The browser’s font APIs expose both the overall font-set state and individual faces; see MDN’s documentation for FontFaceSet.ready and FontFaceSet.load().

await document.fonts.ready is useful as a diagnostic wait, but do not treat its resolution alone as proof that the intended face styled the target text. Confirm the matching face’s status and the network request. If the face is missing from the report, check whether its CSS was loaded and whether the text actually uses the expected family.

3. Verify the font declaration and network request

  1. Open the browser console and network log for the failing CI run. Find the font request and confirm it is actually made, returns successfully, and is not blocked.
  2. Compare the request URL and response with the @font-face declaration. Check relative paths, asset packaging, redirects, and whether the CI job can reach the font host.
  3. Check browser console messages for origin policy, content security policy, certificate, or decoding problems.
  4. Match the CSS family, weight, style, and character subset to the face declaration. A request for regular weight does not establish that bold text has its bold face.
  5. Render representative text. A font file can load while a particular character falls back because that glyph is not in the face’s subset.
/* Example only: use the actual family, file path, and weights from your app. */
@font-face {
  font-family: "Your Family";
  src: url("/assets/your-family-regular.woff2") format("woff2");
  font-style: normal;
  font-weight: 400;
  font-display: swap;
}

.article-title {
  font-family: "Your Family", sans-serif;
  font-weight: 400;
}

If one face is in error or remains loading, fix the cause rather than raising the screenshot timeout and assuming that capture is correct. A useful diagnostic artifact includes the font report, failed request details, console messages, browser and Playwright versions, OS/container image, and the screenshot call log.

4. Make local and CI rendering environments comparable

Playwright’s visual comparison guidance says browser rendering can vary with host OS, browser version and settings, hardware, power source, headless mode, and other factors. Generate baselines and run comparisons in the same environment wherever practical. See Microsoft’s visual comparisons guidance.

  • Pin the Playwright package version and install the matching browser binaries with the Playwright CLI.
  • Use the same OS or container image for baseline generation and CI comparison when possible.
  • Compare the same browser project and headless setting. A font being available on a developer’s machine does not mean it exists in a clean Linux container.
  • If your app fetches fonts remotely, verify the CI network can reach the origin and that the response is not intermittently blocked or redirected.
  • Vary one axis at a time: Playwright/browser version, engine, OS/container, headless mode, font response, or baseline environment.
  • When stability is the priority, use one CI worker while diagnosing. Playwright’s CI guidance also describes using its official Linux Docker image or installing dependencies with the CLI.

For a quick isolation, run the same test and pinned Playwright version in the CI container locally if that is part of your workflow, then compare with CI logs. This helps distinguish an environment difference from a timing or browser issue; it does not identify the cause by itself.

5. Check whether the browser engine is involved

If the failure appears only in WebKit on Linux, compare a controlled reproduction with the same Playwright version in Chromium or Firefox. If Safari fidelity is important, also consider WebKit on macOS: Playwright notes that its WebKit build comes from upstream WebKit and that macOS is closer to Safari behavior. See the Playwright browser guide.

A September 29, 2026 report describes a clean Linux/amd64 Ubuntu 24.04 reproduction using Playwright 1.63.0 and bundled WebKit 26.6: the document was complete while the font set remained loading, and the screenshot timed out waiting for fonts. The reporter’s Playwright 1.60.0 control completed. The report does not bisect the intermediate versions, establish the responsible WebKit change, or reduce the problem to a minimal offline example. Treat it as a specific unresolved report, not a confirmed explanation for all missing-font or CI failures: Playwright issue #42986.

6. Keep font correctness separate from screenshot stability

toHaveScreenshot() waits for two consecutive screenshots to match, which helps with visual stability. But a stable fallback-font rendering can also match itself. If the font is part of the visual contract, keep an explicit check for the required face in the test in addition to the screenshot assertion.

The issue report above mentions PW_TEST_SCREENSHOT_NO_FONTS_READY=1 as a bypass that allowed one capture, but a later ordinary capture timed out again. Skipping the wait can produce a screenshot before the intended font is ready. Use such a bypass only to isolate the wait behavior; it does not repair font state or establish a correct baseline.

7. Troubleshooting checklist

Problem Likely cause Action
Call log ends at “waiting for fonts to load” Font set or face has not settled; a request may be pending or errored Wait for target content, log set and face states, inspect request and console, then fix the underlying request or declaration
Font report is empty or expected family is absent Stylesheet/face declaration is missing, late, or not applied Confirm stylesheet load, computed family on target text, and CSS import timing
Face says loaded but screenshot still looks wrong Wrong weight/style is used, text is outside the font subset, or CSS cascade selects another family Inspect computed styles and matching face descriptors; test the exact text and weight
Font request fails only in CI Network access, origin policy, certificate, path, or asset packaging differs Inspect request URL/status and console output from CI; make the asset available to that environment or correct the policy/path
Only WebKit Linux fails Engine/platform-specific behavior or version interaction may be involved Compare engines and versions while keeping other axes fixed; preserve a minimal reproduction and report evidence if reproducible
Screenshot passes but uses fallback typography Visual assertion checks image stability, not intended font identity Add a required-face diagnostic/assertion before capture and inspect actual font requests
Increasing timeout changes nothing The face may be stuck, unreachable, or errored rather than merely slow Diagnose request and face status before adjusting timeouts; only increase a timeout when evidence shows a legitimate slow response

8. Performance, reliability, and cost

Font diagnostics add browser work, so keep verbose reports for failing cases or a focused diagnostic test once the cause is understood. A remote font request adds a dependency on network access and origin behavior; serving the intended test font reliably in the test environment reduces that uncertainty. Avoid arbitrary sleeps: they make the run slower without proving the face loaded.

For reliability, pin browser and Playwright versions, align baseline and CI environments, wait for application state rather than a guessed delay, and assert the font when its identity matters. For cost, Playwright’s documented CI choices include installing browser dependencies or using its official Linux container; choose a reproducible setup that fits your existing CI provider and avoid adding workers while investigating a timing-sensitive failure. No general performance benchmark or failure rate follows from the version-specific issue report.

Or skip the browser setup

If your goal is a clean capture of a live page rather than a Playwright visual regression test, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF. See the API documentation.

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res); // In Node.js, write the response bytes with node:fs.

Before capture, ScreenshotNeo accepts the cookie or consent banner like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000.

Sign up for 1,000 free screenshots a month, with no card.

FAQ

Does document.readyState === 'complete' mean web fonts are ready?

No. Check the font set and the specific face used by the target text, and inspect its request.

Can a screenshot test pass with the wrong font?

Yes. A fallback rendering can be stable and match across consecutive captures. Assert the required face separately when it matters.

Should I disable Playwright’s font wait?

Only as a diagnostic to isolate a wait issue. It can capture before the intended face loads and is not a correctness fix.

Is the reported WebKit issue the cause of my CI failure?

Not necessarily. It is a specific unresolved reproduction. Compare versions and engines, and collect a minimal reproduction before attributing your failure to it.