You're staring at a PDF export that looked fine in Chrome yesterday and now it's broken in production. The invoice footer is missing, one table spills onto a second page mid-row, and a font that matched the mockup fell back to Arial.
That's the core code HTML to PDF problem. It isn't one problem, and it isn't one tool. It's a method-selection decision between browser print, client-side JavaScript, headless browsers, and hosted APIs, each with different trade-offs for fidelity, privacy, latency, scaling, and maintenance.
Table of Contents
- Why HTML to PDF Is Harder Than It Looks
- Browser Print and CSS Paged Media
- Headless Chrome With Puppeteer and Playwright
- Client-Side Libraries for Quick Conversions
- Calling the PDFWix API From Any Stack
- Engineering Details That Decide Whether Your PDF Looks Right
- Picking the Right Method and Keeping Data Private
Why HTML to PDF Is Harder Than It Looks
The first time a senior developer ships invoice exports, the failure is usually embarrassing, not subtle. A totals row splits across pages, a web font doesn't embed, or a dashboard that looked correct in the browser turns into a clipped, half-empty PDF. That's why HTML to PDF work is rarely about “how do I convert this file” and more about “which rendering path fits this layout.”

The modern web gives you four legitimate execution models. Browser print through Ctrl+P or Command+P is free, uses standards-based print CSS, and keeps data on the user's machine, but it inherits local browser settings and user behavior. Client-side libraries like html2pdf.js and jsPDF run entirely in the browser, which is useful when you can't upload data, but they hit a fidelity ceiling on complex, image-heavy, or dynamic pages. Headless browsers such as Puppeteer, Playwright, and wkhtmltopdf give you real rendering in a controlled environment, which usually means better output and more operational work. Hosted APIs expose the same conversion logic over HTTP, which cuts infrastructure overhead but adds a third party into the loop.
If you've only ever seen tutorial code, this part gets missed. Tutorial code assumes a static page, a happy font load, and a single page of content. Production documents are invoices, long reports, legal exports, and dashboards with asynchronous data, where pagination and asset timing decide whether the PDF is usable.
The right answer is usually not “just use Puppeteer.” The right answer is the lightest method that can still render your real layout faithfully.
The standards story matters too. Guidance for HTML report generation now treats @media print, @page, repeating headers, and break-inside: avoid as normal tools, not hacks, and browser print flows in Chrome, Firefox, Safari, and Edge all support saving as PDF through the built-in print dialog. That's a big shift from screenshot-based exports, because HTML and CSS can be authored once and repurposed for paginated output across major desktop browsers.
| Method | Fidelity | Privacy | Setup Cost | Best For |
|---|---|---|---|---|
| Browser print | Good for well-styled documents | Strong, stays local | Low | Internal reports, simple invoices, quick user-side exports |
| Client-side JS | Moderate, depends on DOM complexity | Strong, stays local | Low to medium | Prototypes, privacy-sensitive single-page exports |
| Headless server | High for real browser rendering | Medium, data leaves the browser | Medium to high | Dashboards, invoices, automated batch jobs |
| Hosted API | High, depends on vendor engine | Medium, data is sent to service | Low upfront | Teams that want to ship fast without running infra |
For a plain-language overview of the format itself, the PDFWix guide to what a PDF is is a useful companion, especially if you're deciding whether the output needs to behave like a document or just a printable page.
Browser Print and CSS Paged Media
The most underrated path is still the simplest one. If your document already lives in HTML, you can add print styles, let the browser paginate, and hand the user the built-in Save as PDF flow. That keeps the document local, avoids uploads, and works surprisingly well when the layout is mostly text, tables, and a few images.
@media print {
@page {
size: A4;
margin: 16mm;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
thead {
display: table-header-group;
}
.avoid-break {
break-inside: avoid;
page-break-inside: avoid;
}
.page-break-before {
break-before: page;
page-break-before: always;
}
.page-number::after {
content: counter(page);
}
.totals {
break-before: page;
}
}
That block handles the basics well. @page controls page size and margins, thead repetition keeps table headers visible across pages, and break-inside: avoid helps keep cards, totals, and signature blocks together. The problem is browser inconsistency at the edges, especially when teams expect exact positioning, fixed headers on every page, or pixel-perfect parity with a design tool.
A practical rule is to reserve browser print for documents that can tolerate the browser's pagination decisions. If the content is mostly static, the user needs local privacy, and the output doesn't need server automation, this route is hard to beat. If you need unattended generation, a stable paper size, or strict control over late-loading assets, it starts to wobble.
Good print CSS beats clever export code when the document is mostly text and the page structure is predictable.
For teams that are still trying to get the browser path right, the PDFWix browser tools guide is useful background because it shows where local browser workflows fit into the larger PDF pipeline.
Headless Chrome With Puppeteer and Playwright
When the browser print dialog is too unpredictable, a headless browser is the workhorse. It renders with a real Chromium engine, so your CSS, fonts, and layout logic are exercised in something much closer to production reality than a screenshot library.
const puppeteer = require('puppeteer');
async function renderPdf() {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 1800 });
await page.goto('https://example.com/invoice', {
waitUntil: 'networkidle0'
});
await page.evaluate(() => document.fonts.ready);
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
margin: {
top: '16mm',
right: '12mm',
bottom: '16mm',
left: '12mm'
},
preferCSSPageSize: true
});
await browser.close();
}
renderPdf();
Playwright is similar if that's already your stack.
const { chromium } = require('playwright');
async function renderPdf() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setViewportSize({ width: 1280, height: 1800 });
await page.goto('https://example.com/invoice', { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
margin: {
top: '16mm',
right: '12mm',
bottom: '16mm',
left: '12mm'
}
});
await browser.close();
}
renderPdf();
The failure mode is timing. Fonts may still be loading, images may not be decoded, and API-driven dashboard data may still be mutating the DOM when the PDF snapshot happens. Waiting for networkidle0, checking a custom readiness flag, and confirming document.fonts.ready before export prevents the classic blank section or clipped chart problem.
If you're automating this in a test or CI pipeline, the headless-browser setup also pairs well with guidance like speed up automated testing, because the same rendering mechanics that make PDF output stable also make browser checks more reliable.
Docker is the deployment pattern that keeps this sane. Put Chromium, fonts, and your export script in the same container image, then run the job in CI or a worker so the rendering environment doesn't drift between laptops and production.
Client-Side Libraries for Quick Conversions
When you can't run a server, browser-only libraries are still useful. html2pdf.js wraps html2canvas and jsPDF, which makes it a fast way to turn a DOM node into a downloadable file without moving data off the device.
html2pdf().from(document.getElementById('invoice')).save();
That single line is attractive because it's easy to ship. The trade-off is that html2canvas rasterizes the DOM, so text becomes pixels, searchability drops, and small layout defects show up fast on dense tables or dashboard widgets.
If you need more control, jsPDF can build the PDF manually from structured data instead of markup. That's a better fit when the source is a dataset, not an existing page, because you decide exactly where the text, lines, and tables land.
Client-side PDF generation is strongest when privacy matters more than layout perfection.
The limit is still fidelity. Web fonts can clip if they aren't ready, images can get compressed in ways you didn't expect, and complex nested components often need more hand-built rules than teams expect. These tools are solid for prototypes, local-only exports, and single-page app screens with stable markup, but they're the wrong choice for heavily dynamic reports that need browser-accurate pagination.
Calling the PDFWix API From Any Stack
Hosted conversion is the fastest way to ship when you don't want to own Chromium. With the PDFWix developer API, you authenticate once, send HTML in a POST request, and receive a PDF response you can store, stream, or hand back to the browser.

A Node example looks like this in practice.
import fs from 'node:fs';
const response = await fetch('https://api.pdfwix.com/convert', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFWIX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
input_type: 'html',
html: 'Invoice
'
})
});
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync('invoice.pdf', buffer);
Python follows the same shape.
import os
import requests
response = requests.post(
"https://api.pdfwix.com/convert",
headers={
"Authorization": f"Bearer {os.environ['PDFWIX_API_KEY']}",
"Content-Type": "application/json",
},
json={
"input_type": "html",
"html": "Invoice
",
},
)
with open("invoice.pdf", "wb") as f:
f.write(response.content)
The point is not just convenience. A hosted API removes browser packaging, isolates rendering from your app server, and lets you keep the response in memory if the document is sensitive. The PDFWix developer documentation is the place to confirm payload shape and endpoint behavior before wiring it into a production job.
Engineering Details That Decide Whether Your PDF Looks Right
A PDF can render and still fail the job. A report may open, but the page breaks land in the wrong place, the logo shifts, or a table row splits where it should stay together. That usually comes down to the method you chose and the rendering rules you enforced, not the HTML itself.
Web fonts need to be embedded with @font-face, because a fallback font changes line wrapping and can push content onto a different page. Critical CSS should be inlined early, because a late stylesheet can race the renderer and leave the first page half-styled. In production, that is often the difference between a file that looks stable in preview and one that breaks as soon as a real invoice template or long report hits it.
Images are another practical decision, not just a visual one. PNG is safer when transparency matters, JPEG is usually lighter for photos, and WebP works only when the browser and renderer support it cleanly. If you are generating from async content, do not trust window.load alone, because the page may still be waiting on data fetches, chart hydration, or delayed state updates after the event fires.
Paginated layouts need explicit structure
Paged media features matter once a simple print stylesheet stops being enough. Named pages help when a cover page needs different margins, running headers can be built with position: running(), and CSS counters keep page numbers synchronized without manual scripting.
The trade-off is control versus compatibility. These rules are useful in browser-print workflows and headless Chrome, but some client-side libraries ignore parts of paged media entirely, so a layout that looks fine in one pipeline can drift in another. For layouts that must survive export across environments, structure matters more than decorative CSS.
A simple checklist catches most production defects:
- Embed fonts early: Use
@font-facefor every font family your export depends on. - Inline the critical CSS: Keep the first render from depending on late network fetches.
- Wait for the final data state: Use a custom readiness flag for SPAs, not just page load.
- Test ugly pages first: Nested tables, long forms, and image-heavy dashboards expose layout problems quickly.
- Check accessibility structure: If the PDF will be shared broadly or archived, review PDF accessibility best practices before you ship the template.
If the PDF comes from a dashboard, the export should wait for the dashboard's own readiness signal, not the browser's idea of “done.”
A good test plan renders a benchmark set of difficult layouts and compares the output before rollout. That includes invoices with long line items, reports with repeated headers, and dashboards that load data in stages. It is boring work, but it is cheaper than finding a clipped totals section after finance has already sent the file to a customer.
Picking the Right Method and Keeping Data Private
Start with the file owner, the sensitivity of the document, and the amount of output you expect. Those three factors usually point to the right HTML-to-PDF method without much argument, because the wrong choice shows up fast in production.

If the user owns the machine and the file should stay on that device, browser print or a client-side library is the simplest path. If your team owns the runtime and the layout has to render exactly, headless Chrome usually fits better. If you do not want to run Chromium yourself, a hosted API keeps the workflow straightforward while still giving you a conversion service you can call from any stack.
Privacy is a routing choice, not just a policy line. Browser-print and client-side tools keep files local, while headless servers and hosted APIs move content into infrastructure you control or rent. That matters for contracts, regulated reports, and internal documents where upload paths are part of the review. If you need browser-based PDF tools that don't require uploading, this guide from PDFWix is the kind of reference that helps teams separate local-only workflows from server-based ones.
A practical wiki checklist is sufficient.
- Choose the lightest method that matches the layout: Do not run headless rendering if browser print already handles the page.
- Decide where the document lives during conversion: Local device, your server, or a hosted service.
- Confirm the output destination: Download, email, storage bucket, or browser response.
- Test fonts, images, async data, and pagination together: Most failures come from the interaction, not one feature alone.
For teams that want browser-based tools and a developer API in one place, PDFWix offers HTML-to-PDF conversion alongside local PDF utilities, so you can choose a workflow that fits the document instead of forcing every job through the same pipe.