· Updated · Engineering · By Simon Chiu

Consistent Font Rendering in Headless Chrome

Why the same page renders with different fonts on different machines, measured with pixel diffs, and how to make fonts deterministic on a managed renderer.

Line illustration of the RenderScreenshot mascot measuring the same font shapes in two browser windows

The same HTML can produce two different screenshots on two machines, and the cause is almost always fonts. Either the page asks for a font that one machine does not have, or the screenshot fires before the web font arrives. Both make Chromium draw the text in a fallback font. We measured how big that change is with pixel diffs, found one case where the usual fix (document.fonts.ready) does not work, and wrote down how we deal with fonts on a renderer where we cannot install any. This post is part of how we built RenderScreenshot.

Short answer: a font that is already in the HTML renders the same every time (0 pixels changed in 5 repeat renders). A fallback font changed 4.2% of the image, about 63% of the text pixels. A web font that loads during page load is safe with load. A web font that a script adds after load is not, even with document.fonts.ready. Waiting for network idle caught it.

Last tested: 2026-10-15 on an Apple M1 Pro, macOS 27.0.1, Chrome headless shell 148.0.7778.97 via Puppeteer 24.43.1, Node 22.18.0, pixelmatch 7.2.0 (threshold 0.1). Each case ran in a fresh browser context; the full experiment ran 3 times with identical results. Script: experiment.mjs.

Where render differences come from

A screenshot's text goes through three steps, and each one can differ between machines:

flowchart LR
  A[CSS font-family list] --> B{Font available<br/>when text is painted?}
  B -- "yes: web font loaded or embedded" --> D[Glyph rasterizer]
  B -- "no: not installed / not loaded yet" --> C[Fallback font chosen<br/>by this machine]
  C --> D
  D --> E[Pixels]
  1. Font matching. CSS names fonts (font-family: Inter, Helvetica, sans-serif). The browser takes the first one it can use on this machine. sans-serif and system-ui mean different fonts on macOS, on a Debian container and on a managed cloud browser.
  2. Timing. A web font is a network request. If the capture happens before it arrives, the browser paints the fallback (with font-display: swap) or invisible text (with block, up to its timeout).
  3. Rasterization. Hinting, anti-aliasing and subpixel positioning turn outlines into pixels. These differ by operating system and Chrome flags, but they are small compared with steps 1 and 2.

Most "flaky font" bugs in screenshot pipelines are step 1 or step 2. Teams often start by tuning step 3, because it has Chrome flags.

The experiment

We wrote one test page: a 600-weight heading and a 400-weight paragraph in Inter, with digits, curly quotes and the fi/ffi ligatures. The reference render embeds the Inter WOFF2 files in the HTML as data: URIs, so nothing loads from the network. Every other case is compared with that reference using pixelmatch.

The variants:

  • Remote font: the same Inter files served over HTTP with an 800 ms delay and font-display: swap, captured at different wait conditions.
  • Late font: the @font-face rules are added by a script 50 ms after the load event. Tag managers, A/B testing scripts and "load CSS later" snippets do this.
  • Synthetic bold: only the 400 weight is available, so Chromium fakes the 600 heading by thickening the 400 outlines.
  • Fallbacks: Arial, sans-serif and system-ui, which is what the page gets on a machine without Inter.
  • Hinting: the reference page rendered with --font-render-hinting=none.

The core of the measurement is short:

// fragment from experiment.mjs
async function render(browser, name, { waitUntil = 'load', fontsReady = false } = {}) {
  const context = await browser.createBrowserContext(); // fresh cache per render
  const tab = await context.newPage();
  try {
    await tab.setViewport({ width: 1200, height: 360, deviceScaleFactor: 1 });
    await tab.goto(`${base}/${name}`, { waitUntil });
    if (fontsReady) await tab.evaluate(() => document.fonts.ready);
    return PNG.sync.read(await tab.screenshot({ type: 'png' }));
  } finally {
    await context.close();
  }
}

function diff(a, b) {
  const changed = pixelmatch(a.data, b.data, null, a.width, a.height, { threshold: 0.1 });
  return { changed, pct: ((changed / (a.width * a.height)) * 100).toFixed(2) };
}

Results

The image is 1200x360 (432,000 pixels). The reference has 28,423 pixels of "ink" (non-white), so a full font swap can change at most a few percent of the image. Read the last column as "how much of the text changed".

Case Pixels changed % of image % of text pixels
Embedded font, 5 repeat renders (worst) 0 0.00% 0%
Remote font, domcontentloaded 18,045 4.18% 63%
Remote font, load 0 0.00% 0%
Remote font, domcontentloaded + document.fonts.ready 0 0.00% 0%
Late font, load 18,045 4.18% 63%
Late font, load + document.fonts.ready 18,045 4.18% 63%
Late font, networkidle0 0 0.00% 0%
Synthetic bold (only weight 400 available) 10,742 2.49% 38%
Fallback Arial, sans-serif 18,045 4.18% 63%
Fallback system-ui 18,304 4.24% 64%
Embedded font with --font-render-hinting=none 0 0.00% 0%

What the numbers say

Determinism is not the problem. The same page with an embedded font rendered pixel-identical five times in a row. Headless Chrome on one machine is deterministic for text. If your renders flicker between runs, look for timing or network, not randomness.

An early capture is a fallback capture. domcontentloaded with a slow font produced exactly the same pixels as the Arial fallback (18,045 changed in both cases). It is not a "slightly off" render. It is a different font.

load covers fonts that the page requests while it loads. Chromium keeps the load event open for fonts that layout requests during the initial load. With an 800 ms font delay, load waited for it, and so did document.fonts.ready.

document.fonts.ready is not a guarantee. This is the result that surprised us. When the @font-face rule arrives after load, fonts.ready resolves at once, because at that moment no font load is pending. The capture got the fallback. networkidle0, which waits until no requests have run for 500 ms, caught the late font request and produced a correct render. If you do not control the page, a network-idle wait is the safer default for text-heavy captures.

Missing weights matter almost as much as missing fonts. Loading only Inter 400 and letting the browser synthesize the 600 heading changed 38% of text pixels. Load every weight and style you use, or set font-synthesis: none to make the gap visible instead of faked.

The hinting flag changed nothing here. On macOS, --font-render-hinting=none had no effect on our page. On Linux it can change glyph shapes slightly, which is why it appears in many Puppeteer Docker guides. Either way it cannot fix a missing font, which is the larger effect by far.

Fonts on a renderer you cannot change

RenderScreenshot renders pages in Cloudflare's Browser Rendering, a managed Chromium. We cannot install fonts, change the fontconfig setup, or add Chrome flags per request. The font list is what Cloudflare publishes in its supported fonts list. It includes Noto Color Emoji and fonts for CJK, Arabic, Thai and several Indic scripts, which our emoji and localized screenshots guides cover.

That constraint shaped how we think about fonts. We cannot pin fonts on the machine, so the font has to travel with the page. In practice there are three cases.

1. You send the HTML (templates, social cards)

You control everything, so make the render independent of the network:

  • Embed the font in the HTML as a data: URI, or link a web font with font-display: block.
  • Embed every weight you use. The synthetic-bold row above is what happens otherwise.
  • Name a fallback with similar metrics after your font, so a failure is less visible.

A request that embeds a subset of a font. The base64 is shortened here; the HTML to image guide has complete templates:

{
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><style>@font-face{font-family:Brand;font-weight:700;font-display:block;src:url(data:font/woff2;base64,d09GMgABAAAA...) format('woff2')}body{margin:0;background:#0f172a;color:#f8fafc;font:700 64px/1.2 Brand,Helvetica,Arial,sans-serif}</style></head><body><h1>Release notes</h1></body></html>",
  "preset": "og_card",
  "wait": { "until": "networkidle" }
}

A subset that covers only the characters on your card (for example Latin) keeps the request small. Subset with a tool such as pyftsubset from fontTools, and check the font license allows embedding.

2. You capture your own site

Your pages already load web fonts. Make sure the capture waits for them:

  • Load fonts with a <link> or @font-face in the initial HTML, not from a script that runs after load.
  • Use wait.until: "networkidle" if any font, stylesheet or script arrives late. The wait strategies guide compares the conditions.
  • Self-host fonts on the same origin. A third-party font CDN adds a DNS lookup, a TLS handshake and one more thing that can be slow at capture time.

3. You capture other people's pages

You control nothing in the page, so control the wait:

  • Prefer networkidle over load for text-heavy pages. The late-font row shows why.
  • Do not inject a font with page.styles and expect it on the capture. The renderer adds injected styles after the wait step, just before the capture, so the font file can still be downloading. The emoji guide describes this timing.
  • Accept that a site which never loads its font in a headless browser (blocked by bot protection, for example) renders in a fallback. You cannot fix that from the request; compare a sample of captures against the live site before you rely on them.

A font check for your own pages

If you render your own templates, you can test for this class of bug in CI. This script loads a page and fails if any declared font face is not loaded at capture time. It catches missing files, wrong URLs and late @font-face rules:

// font-check.mjs: fail if any @font-face on the page is not loaded at capture time.
// Usage: node font-check.mjs https://example.com
// Node 22, [email protected]
import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({ headless: 'shell' });
try {
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'networkidle0', timeout: 30_000 });
  const faces = await page.evaluate(async () => {
    await document.fonts.ready;
    return [...document.fonts].map((f) => ({
      family: f.family, weight: f.weight, style: f.style, status: f.status,
    }));
  });
  const bad = faces.filter((f) => f.status !== 'loaded' && f.status !== 'unloaded');
  const unused = faces.filter((f) => f.status === 'unloaded');
  console.table(faces);
  if (unused.length) console.log(`${unused.length} declared face(s) never used on this page (fine, but check your subsets)`);
  if (bad.length) {
    console.error(`${bad.length} font face(s) failed or still loading`);
    process.exitCode = 1;
  }
} finally {
  await browser.close();
}

We ran it on 2026-10-15 against https://example.com (no web fonts, empty table, exit 0), against a local page that adds its @font-face 50 ms after load with an 8-second font delay (the network-idle wait held the check until the face showed loaded, exit 0), and against the same page with a font URL that returns 404 (status error, exit 1). The check uses a network-idle wait for the same reason as the late-font row above.

Rendering differences you cannot remove

Some differences are real and you should plan for them rather than chase them:

  • Emoji and symbols come from the renderer's fonts. A page viewed on an iPhone shows Apple emoji; a Linux renderer shows Noto. See the emoji guide.
  • Device scale factor changes every glyph. Compare 1x renders with 1x renders. Retina (viewport.scale: 2) is a different image, not a sharper copy.
  • Browser version updates can change the rasterizer. If you run visual regression tests against a managed browser, keep a tolerance (pixelmatch threshold, or a maximum changed-pixel count) and re-baseline when the browser version changes. The request logs help you see when a batch of diffs starts.

What to take away

  • Same machine, same HTML, embedded font: the render is deterministic. Start there.
  • A font that is missing or late does not render "a bit differently". It renders in a different font, which changed about two-thirds of the text pixels in our test.
  • document.fonts.ready waits only for fonts already requested. Use a network-idle wait when you do not control the page.
  • On a managed renderer, ship the font with the page.

To check the effect on your own templates, render one card with the font embedded and one with a linked font at domcontentloaded, and diff them with the script above. Create a free account to run the same comparison through the API, and see how we chose the preset sizes those cards use. For the image side of the same timing problem, read our lazy-loading study.

Related in this topic