Custom fonts in HTML-to-PDF rendering work the same way they work on a web page: declare an @font-face, point at a font file, use the family name in CSS. Chromium embeds the font in the PDF automatically. The catch is timing. If you call page.pdf() before the font finishes loading, the snapshot uses the fallback font, and a default Times New Roman ends up baked into your invoice.
This guide covers every gotcha: Google Fonts versus self-hosting, document.fonts.ready, variable fonts, CJK fallbacks, and the FOUT trap that silently breaks production renders.
Why fonts in PDF generation are different from fonts on the web
A PDF render is a one-shot snapshot. On a web page, a font that loads 400ms late causes a brief flash. In a PDF, that same 400ms means the wrong font is permanently embedded in a file you just emailed to a customer. There is no second chance.
Chromium loads @font-face declarations the same way during PDF rendering as it does during normal browsing. It downloads the file, parses it, and registers the family. The difference is that page.pdf() does not wait for any of that on its own. If the snapshot fires before the font is registered, the PDF uses whatever was rendered at that exact moment, which is usually a system fallback.
The fix is simple: wait for fonts before snapshotting. The complications come from how you wait, where the font lives, and how Chromium decides whether the font is "ready."
What is the difference between embedded and referenced fonts in a PDF?
A PDF can either embed the font file (subset or full) directly inside the document, or reference a font by name and assume the viewer has it installed. Embedded fonts render identically everywhere. Referenced fonts only work if the reader has the same font on their machine, which on a typical user's laptop means Helvetica, Times, and not much else.
Chromium always embeds fonts during page.pdf(). There is no "reference only" mode. This means every @font-face you declare ends up in the PDF, subset to only the glyphs you actually used. A typical Inter subset for an invoice adds 30-80 KB to the file. A full CJK font without subsetting can add 8 MB or more, which is why subsetting matters for documents that mix scripts.
| Approach | Embedded? | File size impact | Renders identically? |
|---|---|---|---|
@font-face with woff2 URL | Yes (subset) | +30-100 KB per font | Yes |
@font-face with base64 data URI | Yes (subset) | +30-100 KB per font | Yes |
font-family: Helvetica (no @font-face) | No | 0 KB | Only if reader has Helvetica |
| Variable font with pinned axes | Yes (subset) | +50-150 KB | Yes |
How do you load Google Fonts in a Playwright PDF render?
Drop the standard Google Fonts <link> tag in your HTML <head>, then wait for both the network and the font registry before snapshotting. The two-step wait is the part most tutorials skip and the reason most "my font isn't working" bug reports exist.
import { chromium } from 'playwright';
async function renderWithGoogleFont(html) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
return pdf;
}
const html = `
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=block" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; font-size: 14px; }
h1 { font-weight: 700; }
</style>
</head>
<body>
<h1>Quarterly report</h1>
<p>This text is rendered in Inter, embedded in the PDF.</p>
</body>
</html>
`;
const pdf = await renderWithGoogleFont(html);Two things matter here. First, display=block instead of display=swap. Block tells Chromium to hide the text rather than show a fallback while the font loads, which prevents a fallback from being snapshotted if document.fonts.ready fires too early. Second, the explicit await page.evaluate(() => document.fonts.ready). The networkidle wait covers the network side, but fonts.ready covers the parse-and-register side, which is a separate phase.
waitUntil: 'networkidle' is not enough on its own. The CSS file from Google Fonts can finish loading while the woff2 file it references is still in flight. Always combine networkidle with document.fonts.ready for any render that uses external fonts.
Why is self-hosting fonts faster than Google Fonts?
Google Fonts costs you two network round trips: one for the CSS file that declares the @font-face rules, then one per font file the CSS references. On a render server, that adds 100-800ms depending on geography and DNS cache state. Self-hosting the woff2 file directly cuts it to one same-origin request, or zero if you base64-encode the font into the HTML itself.
The base64 path is the fastest because the font is already in memory by the time page.setContent() returns. There is nothing to fetch, nothing to wait for, no DNS, no TLS handshake.
import fs from 'fs';
import { chromium } from 'playwright';
const interBold = fs.readFileSync('./fonts/Inter-Bold.woff2').toString('base64');
const interRegular = fs.readFileSync('./fonts/Inter-Regular.woff2').toString('base64');
const html = `
<html>
<head>
<style>
@font-face {
font-family: 'Inter';
src: url('data:font/woff2;base64,${interRegular}') format('woff2');
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Inter';
src: url('data:font/woff2;base64,${interBold}') format('woff2');
font-weight: 700;
font-style: normal;
font-display: block;
}
body { font-family: 'Inter', sans-serif; }
h1 { font-weight: 700; }
</style>
</head>
<body>
<h1>Invoice #INV-2026-042</h1>
<p>Total: $4,500.00</p>
</body>
</html>
`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html);
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });Notice there is no waitUntil: 'networkidle' on setContent here. There is no network to wait for. The base64 URI is parsed inline by Chromium. document.fonts.ready still matters because the font registration step runs asynchronously even for inline fonts.
A 50 KB woff2 file becomes a roughly 67 KB base64 string (base64 expands by ~33%). For a typical multi-weight Inter subset, the inlined HTML adds about 200 KB. That is small compared to the time saved on every render.
How do you stop FOUT from leaking into a PDF?
FOUT, the flash of unstyled text, is what happens when a fallback font shows briefly before the real font loads. On a web page, it lasts a few hundred milliseconds and most users never notice. In a PDF render, if page.pdf() fires during the FOUT window, the fallback is what gets embedded. The fix is font-display: block, which tells the browser to render text invisibly until the real font is ready.
font-display value | Web behavior | PDF render risk |
|---|---|---|
auto | Browser default (usually block) | Low |
block | Hide text until font loads (max 3s) | Lowest, recommended for PDF |
swap | Show fallback immediately, swap when ready | High, fallback may be snapshotted |
fallback | Block briefly, then fallback | Medium |
optional | Use font only if cached | Very high, rarely loads in time |
For PDF rendering, always use font-display: block. Combined with document.fonts.ready, this makes the timing watertight: the text is hidden until the font is registered, the registration is awaited explicitly, and only then is the snapshot taken.
What about variable fonts?
Variable fonts work in Chromium PDF rendering, including in page.pdf(). The catch is that variable fonts have axes (weight, width, slant, optical size) that you must pin explicitly. Otherwise Chromium uses the default axis values from the font file, which may differ from what you see in your browser dev tools.
<style>
@font-face {
font-family: 'InterVariable';
src: url('/fonts/InterVariable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: block;
}
h1 {
font-family: 'InterVariable', sans-serif;
font-weight: 800;
font-variation-settings: 'wght' 800, 'opsz' 32;
}
body {
font-family: 'InterVariable', sans-serif;
font-weight: 400;
font-variation-settings: 'wght' 400, 'opsz' 14;
}
</style>Both font-weight and font-variation-settings should be set. The font-weight is the high-level CSS hint; font-variation-settings is the low-level axis pin. Setting both is belt-and-suspenders, and avoids subtle differences between Chromium versions.
Variable fonts subset less efficiently than static fonts because the subsetter has to keep the variation tables intact. Expect a 100-300 KB embedded size for a typical variable Inter, versus 50-100 KB for separate static weights. If file size matters more than CSS simplicity, ship two static woff2 files instead.
How do you handle CJK and emoji fonts in a PDF?
Chinese, Japanese, Korean, and emoji glyphs are not in any system font on a typical Linux container, which is what your render server probably is. Without an explicit @font-face, Chromium falls back to whatever system font is installed, and if that font does not contain the requested glyph, you get tofu (☐) instead of the character.
The fix is to declare a CJK font explicitly and let Chromium subset it. Noto Sans CJK and M PLUS are good open-source choices. For emoji, Noto Color Emoji or Twemoji works.
<style>
@font-face {
font-family: 'NotoSansJP';
src: url('/fonts/NotoSansJP-Regular.woff2') format('woff2');
unicode-range: U+3000-30FF, U+4E00-9FFF, U+FF00-FFEF;
font-display: block;
}
@font-face {
font-family: 'NotoEmoji';
src: url('/fonts/NotoColorEmoji.woff2') format('woff2');
unicode-range: U+1F300-1F9FF, U+2600-27BF;
font-display: block;
}
body {
font-family: 'Inter', 'NotoSansJP', 'NotoEmoji', sans-serif;
}
</style>The unicode-range descriptor is what makes this efficient. It tells Chromium to only download the CJK font if a character in that range appears in the rendered page. A pure-Latin invoice never pulls the CJK file. A mixed-language report pulls only the subset of glyphs it actually used.
unicode-range subsetting is also a footgun. If you split a font into multiple @font-face rules with overlapping unicode ranges, Chromium picks one based on which rule was declared first. On long documents with mixed scripts, this can cause missing glyphs that only appear on page 17 of a render. Test with the longest realistic input.
How do you embed custom fonts via the PDF4.dev API?
PDF4.dev supports two paths for custom fonts. The simple path is the google_fonts_url field in the format object, which Chromium injects as a <link> tag during render. The full-control path is to put your @font-face declarations directly in the template HTML, exactly the same way you would with self-hosted Playwright.
curl -X POST https://pdf4.dev/api/v1/render \
-H "Authorization: Bearer p4_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1 style=\"font-family: Inter, sans-serif; font-weight: 700;\">Invoice #001</h1>",
"format": {
"preset": "a4",
"google_fonts_url": "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=block",
"font_family": "Inter, sans-serif"
}
}'The google_fonts_url field is injected as a <link rel="stylesheet"> in <head> before render, then the render path waits for document.fonts.ready automatically. For full control over weight, subsetting, and font-display, ship the @font-face rule in your HTML directly.
Comparison: which font delivery strategy should you pick?
| Strategy | Cold render time added | File size | Reliability | Best for |
|---|---|---|---|---|
| Google Fonts CDN | +100-800ms | Small | Depends on CDN uptime | Prototypes, low-volume |
| Self-hosted woff2 (same origin) | +20-50ms | Small | High | Standard production |
| Base64 inline data URI | 0ms | +33% over raw font | Highest | High-volume, low-latency |
| System font (no @font-face) | 0ms | Smallest | Container-dependent | Risky, avoid |
| Variable font, axis-pinned | +30-80ms | Medium | High | Multi-weight designs |
The right answer for production is almost always self-hosted woff2, base64-inlined for the highest-volume templates. Google Fonts is fine for prototypes but the CDN dependency is a single point of failure on every render.
Common font bugs and how to fix them
Bold renders as regular. You declared @font-face for the regular weight only. Add a second @font-face rule for font-weight: 700 pointing at the bold woff2 file. Chromium does not synthesize bold from a regular weight in PDF rendering; it falls back silently.
Italic renders as upright. Same root cause. Declare a separate @font-face for font-style: italic. Without it, Chromium will render upright text instead of synthesizing an oblique slant.
The font looks slightly different from the browser preview. You are rendering with a different Chromium version than your preview environment. Pin font-variation-settings explicitly if you use a variable font. For static fonts, this is usually a hinting difference between Chromium versions. Unavoidable, but visually negligible at body sizes.
Text shows tofu boxes (☐). A glyph is not in the embedded font's character set. Either the font does not contain that script, or unicode-range subsetting excluded it. Add a fallback font that covers the missing range, or remove the unicode-range descriptor.
Random renders use a different font than expected. This is the FOUT trap. The font finished loading on render N but not render N+1. Switch to font-display: block and wait for document.fonts.ready before page.pdf().
The PDF file size doubled. You inlined a font without subsetting and kept the full 8 MB CJK file. Use a build-time tool like glyphhanger or pyftsubset to pre-subset to the glyphs you actually need.
FAQ
How do I add a custom font to a PDF generated from HTML?
Declare the font with @font-face in your HTML, then wait for document.fonts.ready before calling page.pdf(). Chromium embeds the font in the PDF automatically as long as it loaded before the snapshot. Use font-display: block to prevent a fallback font from being captured during the load window.
Why does my custom font fall back to Times New Roman in the PDF?
Chromium took the snapshot before the font finished downloading. Use waitUntil: 'networkidle' plus document.fonts.ready, or self-host the font file as a base64 data URI to remove the network round trip entirely. The base64 path is the most reliable because there is nothing to wait for.
Should I use Google Fonts or self-host my font files?
Self-host for production. Google Fonts adds 100-800ms per render because Chromium has to fetch the CSS file then the woff2 file. Self-hosting cuts that to a single same-origin request, or zero with base64 inlining. Google Fonts is fine for prototypes and low-volume jobs.
Can I use variable fonts in a PDF?
Yes. Variable fonts work in Chromium-based PDF rendering, but you must pin axis values in CSS with font-variation-settings. Without pinning, Chromium uses the default axis values from the font file, which may not match what you see in browser dev tools.
How do I render Chinese, Japanese, or Korean text in a PDF?
Declare an explicit @font-face for a CJK font like Noto Sans CJK or M PLUS. Without it, Chromium falls back to a system font that may not exist in the rendering container, producing tofu boxes. Use unicode-range to subset only the glyphs you actually use.
Does font-display swap cause problems in PDF generation?
Yes. font-display: swap shows a fallback font until the real font loads. If page.pdf() runs during the swap window, the fallback font is what gets embedded. Use font-display: block instead, or wait for document.fonts.ready explicitly.
How much does a custom font add to PDF file size?
A subset woff2 font typically adds 30-100 KB to the PDF. A full unsubsetted CJK font can add 8 MB or more. Chromium subsets fonts automatically during page.pdf() to only the glyphs that appear on the page, but if you ship the font inline as base64, the subsetter still has to scan the full file first.
Skip the font plumbing entirely
If you want custom fonts in your PDFs without managing @font-face rules, base64 inlining, or document.fonts.ready timing, try the free HTML to PDF converterTry it free. It runs the full Playwright pipeline server-side and handles font loading, FOUT prevention, and Google Fonts injection out of the box. For programmatic use, the PDF4.dev API takes the same HTML and runs it through a warm browser pool, so a render with custom fonts completes in around 300ms instead of the cold-start 1-2 seconds you would see on a fresh Lambda.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.


