Your browser shows the web font; the PDF ships Arial, because a print embeds whatever the layout is holding at the instant it runs.

Short version: wait for the page to apply the font, then print:

await page.waitForSelector(".font-applied"); // whatever selector appears with the new family await page.evaluateHandle("document.fonts.ready"); // only now does it have something to wait for const pdf = await page.pdf({ format: "A4" }); 

On webcapture's API the same fix is one parameter, settle: 1200; the copy-paste request is further down. The rest of this post is why the famous line fails, and why the bug sometimes refuses to happen at all.

The bug that refused to reproduce

We went looking because our own API page promises "web fonts load" and the engine's default settle window is 350 ms. If that promise leaned on luck, better we found out than a customer.

Our first reproduction: @font-face in the initial stylesheet, the font file delayed 800 ms on a local server, printed immediately after load, the way a naive pipeline does. The web font came out in the PDF, five runs out of five. The explanation is the safe regime: Chromium holds the load event until fonts the initial layout needs have arrived, even lazy 800 ms ones. A font that never loses this race cannot explain a complaint that shows up in every HTML-to-PDF engine's issue tracker, so the bug that matters needs a different trigger: a font applied by JavaScript after load, the way hydration and webfont-loader pages do it. Same server, same delay, but the @font-face is injected 300 ms after load, with the file taking another 500 ms:

Print timing Font embedded in the PDF
Immediately after load ArialMT (fallback)
After naive fonts.ready (resolved in 8 ms) ArialMT (fallback)
After a 1200 ms settle the web font

The middle row is the trap. fonts.ready resolved 8 ms after load because nothing had asked for a font yet; on a second instrumented timeline it settled at 252 ms and loadingdone fired at 1029 ms. It is a promise about pending loads, not future ones. In every row fonts.check came back true because the probe ran after the font had landed: the embedded font is decided at print time, not at probe time. And fonts.check lies a second way: it reports true when a locally installed font shares the family name, even if the web font never loaded at all.

Why the short version works

waitForSelector(".font-applied") waits for the moment the family actually switches, so document.fonts.ready finally has something to wait for: the right primitive per MDN, but only after something has asked for the font. When you cannot touch the page, the settle window below works the same way.

On the webcapture API

settle defaults to 350 ms and accepts 0 to 10000, so the short version ships as:

curl -X POST https://webcapture.io/v1/convert \ -H "Authorization: Bearer YOUR-API-KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/report", "settle": 1200}' 

waitFor takes a CSS selector bounded by min(settle, 10 seconds), which covers the .font-applied pattern. Passing javascript: "document.fonts.ready" runs before capture; it is harmless on initial-CSS pages, where load already holds for fonts, and it does not help on pages that apply fonts late, where it inherits the middle-row trap.

What no wait fixes: font-display: optional, where the browser declines the web font for that pageview after its ~100 ms window (it still downloads and caches, so a later visit can render it). And a family name with no @font-face behind it at all, which is a host font problem, not a race.

Checked November 2026 - the demo above re-ran on this date with the same result: print-after-load and naive fonts.ready embedded ArialMT, the 1200 ms settle embedded the web font, and document.fonts.check reported true in all three rows.

Related: HTML to PDF without losing formatting, the render options reference, Gotenberg vs Puppeteer.