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.
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]
- Font matching. CSS names fonts (
font-family: Inter, Helvetica, sans-serif). The browser takes the first one it can use on this machine.sans-serifandsystem-uimean different fonts on macOS, on a Debian container and on a managed cloud browser. - 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 (withblock, up to its timeout). - 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-facerules are added by a script 50 ms after theloadevent. 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-serifandsystem-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 withfont-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-facein 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
networkidleoverloadfor text-heavy pages. The late-font row shows why. - Do not inject a font with
page.stylesand 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.readywaits 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
- How We Built a Screenshot API: Rails, Workers and R2 The architecture of RenderScreenshot: a Rails 8 API, a Cloudflare Worker that drives the browser, R2 storage, and the bugs and fixes that shaped it.
- Cloudflare Browser Rendering API: A Tested Tutorial (2026) Screenshots with Cloudflare Browser Rendering (now Browser Run): REST Quick Actions, the Workers binding, session reuse, limits and cost. Tested code.
- Screenshot API vs Self-Hosted Puppeteer: Real Cost A build-vs-buy cost model for screenshots: servers, Cloudflare browser time, engineering hours and API plans, with a calculator and a capacity script.
- Which Websites Block Headless Chrome? A 200-Site Study We loaded the top 200 Tranco domains in three headless Chrome profiles. 15% of sites blocked or challenged default Puppeteer. Raw data and script.
- How Long Does a Screenshot Take? Latency, Phase by Phase Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.
- Blocking Cookie Banners Automatically: How It Fails How our block.cookie_banners option clicks or hides consent banners in headless Chrome, the code behind it, and eight fixtures that show where it breaks.