The problem: the PDF uses a different font
Your quote template uses Inter. It looks crisp in the browser on your laptop. Then the server renders it, and the PDF arrives in a plain serif face. Headings wrap in new places, a two-page quote becomes three, and the table columns no longer line up.
Other versions of the same bug are worse. A customer name in Japanese prints as a row of empty boxes. An Arabic address comes out as unconnected letters. The thumbs-up emoji in a thank-you note simply vanishes.
Missing fonts are one of the top complaints from teams that run their own headless Chrome. The good news is that the causes are few and easy to test for, one at a time.
Why fonts fall back on a server
When a browser cannot use the font you asked for, it does not fail. It picks the next family in your font-family list, or a system default. So the PDF still renders, just in the wrong face. These are the usual reasons:
- The font is not installed in the container. Your laptop has Arial, Helvetica and dozens of other families. A slim Linux server image may have almost none. A name like
Segoe UIin your CSS points to nothing there. - The file cannot be fetched. The
@font-faceURL points tolocalhost, an internal address, or a page behind a login. The renderer cannot reach it, so the font never arrives. - The font host refuses the request. Browsers load web fonts with CORS rules. If the server holding the file does not allow other sites to use it, the browser discards the font.
- A loading race. The page is printed the moment the HTML is parsed, before the font file finishes downloading. The first render uses the fallback, and that is the one that ends up in the PDF.
- A missing script or weight. The font loads, but it has no glyphs for Chinese or emoji, or you only loaded the regular weight. The browser then borrows glyphs from elsewhere, or fakes the bold.
The fix, step by step
- Find the font your CSS asks for. Look at the
font-familyrules onbodyand your headings. Note every family name and weight the design uses. - Choose how to load it. Use an installed family if one fits. Otherwise link a public font stylesheet, declare
@font-facewith a publichttpsURL, or embed the file as adata:URL. - Load every weight you use. Request 400 and 700 if the design has regular and bold text. One weight alone makes the browser imitate the others.
- End the stack with an installed font. Add a family that is always present, such as
"Noto Sans", before the genericsans-serif. A fallback then looks deliberate, not random. - Render and read the report. Ask for
"response": "url"and checkblocked_resourcesin the JSON. An empty list means no font request was refused. - Confirm the fonts in the PDF. Open the file and check the font list in your viewer, or run
pdffontsfrom Poppler on the command line.
The quickest way to load a web font is a stylesheet link from a public font service. This is the Inter quote header, with installed families behind it:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
<style>
body { font-family: Inter, "Noto Sans", sans-serif; font-size: 10.5pt; }
h1 { font-weight: 700; letter-spacing: -0.01em; }
.quote-meta { font-weight: 600; color: #475569; }
</style>
<h1>Quote Q-3307</h1>
<p class="quote-meta">Valid until 31 October 2026</p>Then check what the renderer did. The "response": "url" field makes the API return JSON instead of the PDF itself, so you can read the report:
{
"html": "<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\"><h1>Quote Q-3307</h1><p class=\"quote-meta\">Valid until 31 October 2026</p>",
"css": "body { font-family: Inter, \"Noto Sans\", sans-serif; } .quote-meta { font-weight: 600; }",
"format": "A4",
"response": "url"
}curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer $CASTPDF_API_KEY" \
-H "Content-Type: application/json" \
-d @quote-request.json \
--output quote-response.json
jq '.blocked_resources' quote-response.jsonFour ways to load a font that work
1. Use a family that is already installed
Nothing loads faster than a font that is already there. These families are installed on the CastPDF renderer, so you can name them in CSS without any link or file:
| Family | Covers | Good for |
|---|---|---|
| Noto Sans, Noto Serif and Noto for other scripts | Latin, Greek, Cyrillic, Arabic and many more | Multilingual documents and safe fallbacks |
Noto Sans CJK, Noto Serif CJK (such as Noto Sans CJK SC) | Chinese, Japanese and Korean | Names and addresses in CJK scripts |
| Noto Color Emoji | Emoji in colour | Notes, reactions and labels with emoji |
| Liberation Sans, Serif and Mono | Latin, same widths as Arial, Times New Roman and Courier New | Designs written for those classic fonts |
| DejaVu Sans, Serif and Sans Mono | Latin plus a wide symbol range | Code samples, symbols and plain reports |
Liberation deserves a closer look. Its letters take the same width as Arial, Times New Roman and Courier New, so text wraps the same way. If your design was made for Arial, list "Liberation Sans" right after it, and line breaks match what you saw on your desktop.
2. Link a public font stylesheet
A <link> to Google Fonts, as in the quote example above, works as it does on a website. Ask only for the weights you need. Each extra weight is one more file to download before printing.
3. Declare @font-face with a public https URL
For a brand font, host the files on a public CDN or storage bucket and declare them yourself. Use one rule per weight, and serve the file with a CORS header that allows other origins:
@font-face {
font-family: "Fjordline";
src: url("https://static.example.com/fonts/fjordline-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Fjordline";
src: url("https://static.example.com/fonts/fjordline-bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
body { font-family: Fjordline, "Noto Sans", sans-serif; }4. Embed the font as a data: URL
A data: URL puts the font file inside your CSS, encoded as base64. No network request is made, so nothing can block it or time out. The cost is size: base64 adds about a third to the file, and it counts toward the rendered HTML limit of 5 MB. Subset the font to the characters you need if it is large.
base64 -w 0 fjordline-regular.woff2 > fjordline-regular.b64
printf '@font-face { font-family: "Fjordline"; src: url("data:font/woff2;base64,%s") format("woff2"); }\n' \
"$(cat fjordline-regular.b64)" > fjordline-embedded.cssOther scripts, emoji and self-hosted Chrome
Arabic, Chinese and emoji in one document
List the script fonts in your stack, in order. The browser walks the list for each character, so Latin text uses the first family and other scripts find their glyphs further down. Set dir="rtl" on Arabic blocks so the text runs right to left.
<style>
.address {
font-family: "Noto Sans", "Noto Sans Arabic", "Noto Sans CJK SC", "Noto Color Emoji", sans-serif;
}
</style>
<div class="address">
<p>Delivered to: Lena Ortiz</p>
<p dir="rtl">شارع الملك فهد، الرياض</p>
<p>上海市浦东新区世纪大道 100 号</p>
<p>Thank you for your order 🎉</p>
</div>If you run Chrome yourself
With Puppeteer or Playwright on your own server, you install fonts into the image and wait for them in code. On Debian or Ubuntu images, the system packages cover the same families listed above:
apt-get update && apt-get install -y --no-install-recommends \
fonts-noto fonts-noto-cjk fonts-noto-color-emoji \
fonts-liberation fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*Then make the script wait until web fonts are ready before it prints. Fonts used inside Puppeteer header and footer templates are a separate case, covered in the Puppeteer header and footer guide.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/quotes/Q-3307', { waitUntil: 'networkidle0' });
await page.evaluateHandle('document.fonts.ready');
await page.pdf({ path: 'quote.pdf', format: 'A4', printBackground: true });
await browser.close();How CastPDF handles it
The CastPDF renderer ships with the Noto, Noto CJK, Noto Color Emoji, Liberation and DejaVu families. For anything else, it fetches the font like any web page would, then waits for fonts to finish loading before it prints. You do not need a wait step in your own code.
The renderer only fetches public http and https addresses. A font URL on localhost, on a private network such as 10.x.x.x or 192.168.x.x, or a public name that resolves to one, is refused. The PDF still renders with the fallback font, and the response reports the refusal.
With a binary response, the x-blocked-resources header gives the count. With "response": "url", the blocked_resources array lists up to 10 URLs with their reasons. Failures at the network level appear there too, such as a DNS error or a request slower than 10 seconds.
{
"id": "6a2e1f0c-93b4-4d7e-8c15-2f9a0b6d4e71",
"status": "succeeded",
"pages": 2,
"bytes": 61844,
"url": "https://api.castpdf.com/v1/files/6a2e1f0c-93b4-4d7e-8c15-2f9a0b6d4e71?exp=1791368102&sig=example",
"expires_at": "2026-10-10T09:00:00.000Z",
"blocked_resources": ["http://192.168.1.20/fonts/fjordline.woff2 (address 192.168.1.20:80)"]
}A 404 is not in the report
If the font server answers with an HTTP error, such as 404 for a mistyped file name, the request did not fail at the network level. It is not listed in blocked_resources. Check the PDF itself while you build the template, so a wrong path does not slip through.
Fonts also change how much fits on a page, so fix them before you tune the page breaks, the repeating table headers or the page numbers. The print CSS reference has the full resource rules. If you are weighing a move away from running Chrome yourself, read CastPDF vs Puppeteer.