Browser-side HTML-to-PDF (html2pdf.js) is convenient but has fidelity gaps — anything that involves pagination, headers/footers, or complex CSS is approximate. Server-side via Puppeteer gives true browser rendering. The choice depends on whether you need privacy (no upload) or fidelity (server-side).
Method 1: html2pdf.js (browser, client-side)
html2pdf.js bundles html2canvas (rasterize DOM to image) + jsPDF (build PDF from images). Easiest browser option but produces image-based PDFs — text is not selectable.
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
function htmlElementToPdf(element, filename = "output.pdf") {
const opt = {
margin: 10,
filename,
image: { type: "jpeg", quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
};
return html2pdf().set(opt).from(element).save();
}
// Convert a specific element:
document.querySelector("#download-pdf").addEventListener("click", () => {
const target = document.querySelector("#report-content");
htmlElementToPdf(target, "report.pdf");
});
Three things to know:
- Output is image-based. Text isn't selectable in the PDF — the entire content is rendered as a JPEG inside the PDF. Bad for searchability.
- scale=2 renders at 2x DPI for sharp output. Higher = bigger file.
- useCORS=true is needed for images from other domains. Those images must be served with CORS headers.
Method 2: Puppeteer (Node, Chromium)
For true browser rendering with selectable text and full CSS support, Puppeteer drives Chromium server-side.
npm install puppeteer # downloads Chromium (~250MB)
# Or puppeteer-core if you want to provide your own Chromium
import puppeteer from "puppeteer";
import path from "node:path";
async function htmlToPdf(urlOrPath, outPath) {
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
if (urlOrPath.startsWith("http")) {
await page.goto(urlOrPath, { waitUntil: "networkidle0" });
} else {
await page.goto(`file://${path.resolve(urlOrPath)}`, { waitUntil: "networkidle0" });
}
await page.pdf({
path: outPath,
format: "A4",
margin: { top: "1in", bottom: "1in", left: "1in", right: "1in" },
printBackground: true,
displayHeaderFooter: true,
headerTemplate: 'My Report',
footerTemplate: 'Page of ',
});
await browser.close();
}
await htmlToPdf("https://example.com/report", "report.pdf");
Three things to know:
- waitUntil='networkidle0' waits for all network requests to complete — important for SPAs.
- printBackground=true includes background colors and images (default false matches browser print dialog).
- headerTemplate / footerTemplate use HTML strings with special classes (pageNumber, totalPages, date, title, url).
Method 3: ChangeThisFile API (no Chromium)
If you don't want Chromium in your container, the API runs LibreOffice server-side. Free tier covers 1,000 conversions/month.
const API_KEY = "ctf_sk_your_key_here";
async function htmlToPdf(htmlBuffer, filename = "input.html") {
const form = new FormData();
form.append("file", new Blob([htmlBuffer], { type: "text/html" }), filename);
form.append("source", "html");
form.append("target", "pdf");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return await response.arrayBuffer();
}
// Cloudflare Worker example:
export default {
async fetch(request) {
const html = await request.text();
const pdf = await htmlToPdf(new TextEncoder().encode(html));
return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
},
};
The API uses LibreOffice — supports static HTML + CSS but not JavaScript. For JS-rendered pages, render to static HTML in your build step first.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| html2pdf.js (browser) | Quick downloads, no server cost, privacy | Image-based output (no selectable text), limited CSS |
| Puppeteer (Node) | SPAs, full Chromium fidelity, headers/footers | ~250MB Chromium dep, slow startup |
| ChangeThisFile API | Static HTML, no Chromium in env | No JS support; network call |
Production tips
- For Puppeteer in production, share a browser instance. Don't launch Chromium per request — keep a singleton browser and create new pages. Saves 1-2s per call.
- Use page.emulateMediaType('print') for browser-print CSS. Many sites have @media print rules that hide nav/footers/ads. Without emulation, you get the screen view.
- For Cloudflare Workers, use Browser Rendering API or the API. Puppeteer doesn't run in Workers. Cloudflare's Browser Rendering API is the closest equivalent if you need JS support; otherwise the API.
- For html2pdf.js, render an off-screen element. Don't capture the visible page — UI controls and headers usually don't belong in the PDF. Build a clean HTML container, populate it, capture, then remove.
- Set timeout 60s+. Puppeteer page.goto + page.pdf can take 10-30s on heavy pages. Default 30s timeouts will sometimes fail.
For browser downloads of simple content, html2pdf.js. For SPAs and full fidelity, Puppeteer. For environments without Chromium, the API. Free tier covers 1,000 conversions/month.