The journal · 19 July 2026 · 8 min read
HTML to PDF fonts not loading: how to embed web fonts reliably
You set a brand font, it looks perfect in the browser, and the PDF comes out in Times New Roman. Web fonts are one of the most common HTML to PDF failures, and it is almost always a timing or reachability problem, not a bug. Here is why the font falls back and the three fixes that make it embed every time.
You pick a brand typeface, wire it up with @font-face, and on screen everything looks right. Then you render the page to PDF and every heading comes out in Times New Roman or Arial. The layout survives, the text is there, but the font has silently fallen back to a system default. This is one of the most common HTML to PDF complaints, and the good news is that it is almost never a mysterious bug. It is a timing or reachability problem, and once you know which of three things went wrong, the fix is short.
The short version
Custom fonts fall back in an HTML to PDF render for one of three reasons: the PDF was captured before the web font finished downloading, the font file is at a URL the renderer cannot reach, or a font service refused the request because of the renderer's user agent. Fix them in that order: wait for document.fonts.ready before printing, serve fonts from an absolute, publicly reachable HTTPS URL, and for the last mile of certainty, embed the font as a base64 data URI directly in your @font-face rule so there is no network fetch at all.
Reason one: the render fired before the font loaded
This is the most frequent cause by a wide margin. A web font is an external file the browser downloads after it parses the CSS. If the renderer prints the page the instant the HTML is ready, the font request may still be in flight, so the browser lays out the text in a fallback and captures that. On screen you never notice, because the font swaps in a fraction of a second later. In a headless render there is no "later"; the snapshot is already taken.
The fix is to wait for the browser's own signal that every font has finished loading. Chromium exposes document.fonts.ready, a promise that resolves only once all declared fonts are available. Wait on it before you call the PDF method.
// Puppeteer or Playwright
await page.goto(url, { waitUntil: "load" });
await page.evaluateHandle("document.fonts.ready"); // the key line
await page.pdf({ format: "Letter", printBackground: true });
This one line resolves the majority of "font not loading" reports on its own, because it turns a race condition into a guarantee. If you drive the browser through an API instead, the same idea is exposed as a wait parameter, so you can tell the renderer to hold until fonts are ready without touching the page code. The neighboring case, charts and JavaScript that have not finished, is the same class of problem, covered in the wait for content before HTML to PDF guide.
Reason two: the font URL is not reachable from the renderer
Even with the wait in place, a font that the renderer cannot download will still fall back. This trips people up because the browser on their laptop can reach the file, so the CSS looks correct, but the rendering environment cannot. The usual culprits are a relative path, a font hosted behind a login or a private network, or a cross-origin file blocked by CORS.
Two rules keep you safe. First, always reference fonts with an absolute HTTPS URL, not a relative path, because the renderer may resolve relative paths against a different base than you expect. Second, make sure the file is genuinely public: no auth cookie, no VPN, no signed URL that has expired by render time. A quick test is to open the font URL in a private browser window; if it downloads without a session, the renderer can fetch it too.
@font-face {
font-family: "Inter";
/* good: absolute, public, HTTPS */
src: url("https://cdn.yoursite.com/fonts/Inter.woff2") format("woff2");
font-display: swap;
}
One subtle variant catches teams using Google Fonts or a similar service directly: some font services vary the file they return based on the request's user agent, and a headless browser's user agent can trigger a response that the PDF engine does not use well. If your fonts work in a normal browser but not in the render, either set a standard desktop user agent on the page or, better, self-host the font file so nothing depends on how a third party sniffs the request.
Reason three: embed the font and remove the network entirely
The most bulletproof approach is to stop depending on a network fetch at all. You can inline the font as a base64 data URI right inside the @font-face rule, so the font travels with the HTML and there is nothing to download, nothing to time out, and nothing to block. This is the technique to reach for when a document absolutely must carry its brand type, a contract, a certificate, an invoice, and you cannot risk a fallback.
@font-face {
font-family: "Inter";
src: url("data:font/woff2;base64,d09GMgABAAAAA...") format("woff2");
}
You generate that base64 string once from your .woff2 file and paste it into your stylesheet or template. The trade is a larger HTML payload, so reserve it for the one or two weights the document actually uses rather than an entire family. With the font embedded this way, the render has the typeface in hand the moment it parses the CSS, and it will appear in the PDF on every machine, in every environment, regardless of network conditions. Once the branded document renders correctly, it is often the next step to send it out for signature, and a clean, on-brand PDF is exactly what you want going to a client at that point.
A note for server-side and library renderers
If you render with a library rather than a browser, the rules shift slightly. WeasyPrint and similar Python renderers download web fonts declared with @font-face, so the reachable-URL rule above still applies, but they have no JavaScript, so the document.fonts.ready wait is neither available nor needed. If instead you reference a font by family name expecting it to be installed on the box, remember the server may not have it; either install the font on the machine or, again, embed it. On self-hosted headless Chrome, a page that names a system font like "Helvetica" will fall back if that font is not installed in the container, which is a common surprise when a Docker image ships without the usual desktop fonts.
Getting it right through an API
Rendering server side through an API does not change the underlying rules, but it does hand the fiddly parts to the service. A good rendering API waits for fonts to finish before it prints, runs a real, current browser so modern woff2 files just work, and lets you pass a wait signal for anything slower. You keep your existing @font-face CSS and get the branded type in the output.
curl https://api.sitepdf.com/v1/render \
-H "Authorization: Bearer $SITEPDF_KEY" \
-F url=https://app.example.com/invoices/8842 \
-F wait_for_fonts=true \
-F format=Letter \
-F archive=true
The full parameter list is on the PDF generator API page, and if you are deciding between running your own headless Chrome and calling a service, the Puppeteer alternative page runs that trade off honestly. For the closely related layout issues, see the guides on background colors not printing and margins, headers and footers.
A quick checklist
| Symptom | Fix |
|---|---|
| Font falls back to Times or Arial in the PDF | Wait for document.fonts.ready before printing |
| Works locally, fails in the render environment | Use an absolute, public HTTPS font URL, not a relative path |
| Google Fonts renders fine in-browser only | Self-host the file or set a standard user agent |
| Font must never fall back | Embed it as a base64 data URI in @font-face |
| System font name falls back on the server | Install the font in the container or embed it |
Wait for the fonts to load, serve them from a reachable URL, and embed the critical ones as data URIs. With those three in place, your HTML to PDF output carries the exact typography your users see in the browser, every time.
Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.