headless Chrome as a precise render engine
Anyone who needs to produce reports, invoices, or certificates as PDF can use the same Tailwind CSS template that already runs in the browser. Puppeteer remotely controls headless Chrome and calls page.pdf(), so the layout, including flexbox, grid, and custom properties, is carried over faithfully into the PDF.
Table of Contents
- 1. Why headless Chrome is the most reliable PDF engine
- 2. Setting up Puppeteer and Tailwind
- 3. The core function: page.pdf() in detail
- 4. Print-specific Tailwind CSS for page breaks
- 5. Headers and footers with headerTemplate and footerTemplate
- 6. Practical example: a multi-page report as PDF
- 7. Performance: browser pooling instead of restarting per PDF
- 8. Common pitfalls with fonts and images
- 9. Puppeteer compared to other PDF approaches
- 10. Summary
- 11. FAQ
1. Why headless Chrome is the most reliable PDF engine
The challenge with any PDF generation from HTML is that most PDF libraries implement their own, incomplete CSS subset. Flexbox, CSS grid, custom properties, or modern selectors like :has() are often only partially or not at all supported by classic HTML-to-PDF converters. This is exactly where Puppeteer's decisive advantage lies: it remotely controls real, current Chrome in headless mode, the same rendering engine that Tailwind CSS is already tested against in the browser. What looks correct in the browser looks equally correct in the PDF, because the same engine renders both.
This property makes Puppeteer particularly attractive for teams already using Tailwind CSS in the frontend. Instead of maintaining a second, more limited template language for PDFs, you write a perfectly normal HTML page with Tailwind classes and then export it to a document via page.pdf(). This significantly reduces duplicated layout logic maintenance, because the same utility classes, components, and even the same Tailwind theme work equally for web and PDF.
2. Setting up Puppeteer and Tailwind
Getting started only needs two packages: puppeteer for remote-controlling Chrome and the already existing Tailwind toolchain for the CSS. Puppeteer automatically brings a compatible Chromium version along at install time, so no separate browser installation is needed in the deployment environment. For servers without a graphical interface, for example in Docker containers, a few additional system libraries need to be installed that Chrome requires for headless rendering.
The Tailwind page for the PDF is built like any other HTML page, either as a static file or as a server-rendered template that inserts data from the database. It's important that the compiled Tailwind stylesheet is either inline in the <head> or included via a local file path that Puppeteer can resolve when loading the page. A reference to an external CDN usually works too, but slows down every PDF export by the network latency.
# Install Puppeteer alongside the existing Tailwind build
npm install puppeteer
# Compile the Tailwind stylesheet used by the PDF template
npx tailwindcss -i ./src/pdf.css -o ./dist/pdf.css --minify
3. The core function: page.pdf() in detail
The core of any Puppeteer-based PDF generation is the page.pdf() method. Among other things, it expects the paper format, the page margins, and whether background colors and images should be included in the output. By default, Chrome suppresses background colors when printing, which becomes immediately noticeable in Tailwind layouts with colored boxes or badges. The printBackground: true option is therefore mandatory in practically every Tailwind-based PDF export.
Before calling page.pdf(), the page must be fully loaded, including all images and web fonts. Puppeteer offers page.goto(url, { waitUntil: "networkidle0" }) for this, which waits until there is no network activity for at least 500 milliseconds. For web fonts loaded asynchronously, this is sometimes not enough, which is why additionally calling await page.evaluateHandle("document.fonts.ready") makes sense, to ensure all fonts are actually rendered before the PDF export.
// generate-pdf.js — render a Tailwind HTML page to PDF with Puppeteer
const puppeteer = require("puppeteer");
async function renderPdf(htmlPath, outputPath) {
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" });
// Ensure web fonts are fully loaded before rendering
await page.evaluateHandle("document.fonts.ready");
await page.pdf({
path: outputPath,
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
});
await browser.close();
}
renderPdf("/tmp/report.html", "/tmp/report.pdf");
4. Print-specific Tailwind CSS for page breaks
Chrome respects the CSS properties break-inside, break-before, and break-after during PDF export, which are accessible in Tailwind through classes like break-inside-avoid or break-before-page. Without these classes, a table row or a card can be cut in half right at a page break, which is especially disruptive in multi-page reports. The class break-inside-avoid on a card or table row instructs Chrome to move the element to the next page entirely rather than splitting it, if in doubt.
For forced page breaks, for example between chapters of a report, break-before-page on the first element of a new section works well. It's also worth adding a dedicated @media print rule in the Tailwind stylesheet that hides interactive elements like buttons that serve no purpose in the PDF anyway, as well as navigation bars that only make sense in the browser context. This way, a single HTML template stays usable both for the on-screen view and for the PDF export.
/* pdf.css — print-only overrides layered on top of Tailwind's utilities */
@media print {
.no-print {
display: none !important;
}
.page-break {
break-before: page;
}
table tr {
break-inside: avoid;
}
}
5. Headers and footers with headerTemplate and footerTemplate
Puppeteer allows inserting HTML fragments through the headerTemplate and footerTemplate options, which are repeated on every page of the PDF, for example for page numbers, a company logo, or the date. These templates run in their own, very restricted rendering context that does not load external CSS, which is why inline styles must be used here instead of Tailwind classes. For the dynamic page number, the special CSS classes pageNumber and totalPages are available, automatically filled in by Puppeteer.
A common mistake is forgetting displayHeaderFooter: true, without which the templates are completely ignored, even when correctly passed. Equally important: the page margins in the main document must be chosen large enough that header and footer don't collide with the actual content, since Puppeteer doesn't automatically subtract these areas from the content area.
// Add a repeating footer with dynamic page numbers to every PDF page
await page.pdf({
path: "/tmp/report.pdf",
format: "A4",
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>", // empty header, inline styles only
footerTemplate: `
<div style="font-size:9px;width:100%;text-align:center;color:#64748b;">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
`,
margin: { top: "20mm", bottom: "20mm" },
});
6. Practical example: a multi-page report as PDF
A realistic scenario is a monthly revenue report with a title page, several charts, and a detailed table spanning multiple pages. The title page uses break-before-page on the following section, so the table and charts are guaranteed to start on a new page. The table itself uses break-inside-avoid on every row so no row of numbers is torn apart at a page break, while the table header repeats automatically on every new page via thead, provided the table is built as a native HTML <table> element.
Charts that are rendered in the browser via a JavaScript library must be completely finished drawing before the PDF export. That's why the Puppeteer script additionally waits for a custom signal, for example a CSS attribute like data-charts-ready="true" that the page itself sets once all charts are fully rendered. Only once this attribute has been found via page.waitForSelector() does the script call page.pdf(), to avoid empty or half-drawn charts in the finished document.
7. Performance: browser pooling instead of restarting per PDF
Starting a new Chrome instance typically takes several hundred milliseconds to a few seconds, which is no problem for individual PDF exports but quickly becomes a bottleneck at high request frequency. Instead of starting and stopping a new browser process for every PDF, a production-ready service keeps a single Puppeteer browser instance permanently in memory and only opens a new tab per export via browser.newPage(). After the export finishes, the tab is closed while the browser process itself stays alive.
For very high load spikes, a small pool of several concurrently running browser instances is recommended, similar to a database connection pool. Libraries like generic-pool can be combined directly with Puppeteer to reuse a fixed number of browser instances while also capping parallel PDF exports so the server isn't overloaded by too many concurrent Chrome processes.
// pdf-pool.js — reuse a single browser instance across many PDF exports
const puppeteer = require("puppeteer");
let browserInstance = null;
async function getBrowser() {
if (!browserInstance) {
browserInstance = await puppeteer.launch({ headless: "new" });
}
return browserInstance;
}
async function renderPdf(htmlPath, outputPath) {
const browser = await getBrowser();
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" });
await page.pdf({ path: outputPath, format: "A4", printBackground: true });
await page.close(); // close the tab, keep the browser process alive
}
8. Common pitfalls with fonts and images
A common problem in Docker environments: Chrome can't find the web fonts used in the Tailwind design, because the container image by default ships with none or only very few fonts. If a font is missing, Chrome silently falls back to a system default font without throwing an error, resulting in an entirely different typeface in the PDF than in the browser. The most reliable fix is to copy the required font files directly into the Docker image and reference them via @font-face with a local file path, instead of relying on a Google Fonts CDN.
Images included via relative URL often don't work when loading local HTML files via file://, because the browser can't resolve the relative path. It's more reliable to include images either as absolute file:// paths or directly as base64-encoded data URIs in the HTML, which additionally prevents Puppeteer from having to wait on external HTTP requests before the page counts as fully loaded.
9. Puppeteer compared to other PDF approaches
Puppeteer isn't the only way to generate PDFs from Tailwind CSS layouts. The following overview shows when the overhead of a real Chrome instance pays off and when lighter alternatives suffice.
| Approach | CSS support | Resource use | Best fit |
|---|---|---|---|
| Puppeteer (headless Chrome) | Full, flexbox and grid | High, whole browser process | Complex layouts, high design fidelity |
| wkhtmltopdf (WebKit) | Good, no modern grid | Lower than Chrome | Simpler documents without grid layout |
| Dompdf (PHP) | Limited, no flexbox | Low, no browser needed | Pure PHP environments without Node |
| External PDF service | Depends on provider | Outsourced | When avoiding own infrastructure matters |
For teams that already use Tailwind CSS in the frontend and value pixel-perfect consistency between screen and PDF, Puppeteer remains the obvious choice, because the same Chrome engine renders both representations. The higher resource use of a full browser process can be well compensated for through browser pooling and reusing a running instance.
# Required system libraries for headless Chrome in a minimal Docker image
apt-get install -y \
libnss3 libatk-bridge2.0-0 libx11-xcb1 \
libxcomposite1 libxrandr2 libgbm1 libpango-1.0-0 \
fonts-liberation fonts-dejavu-core
# Verify the Chromium binary bundled with Puppeteer actually starts
node -e "require('puppeteer').launch().then(b => b.close())"
Mironsoft
Tailwind CSS, Puppeteer services, and PDF reporting for Node and PHP projects
Reports and documents as pixel-perfect PDF?
We build Puppeteer services that reliably export Tailwind templates as PDF, including page breaks, headers, footers, and browser pooling for high load spikes.
PDF service
Puppeteer-based render service with browser pooling and a queue
Template design
Tailwind templates with clean page breaks and headers
Deployment
Docker setup with fonts, Chromium, and stable resource usage
10. Summary
Puppeteer turns headless Chrome into a PDF engine that delivers the same rendering accuracy as the browser in which a Tailwind CSS template was developed. Flexbox, grid, and modern CSS features are fully supported because there's no separate, limited PDF library in between. page.pdf() handles the actual export, while print media classes like break-inside-avoid ensure clean page breaks and headerTemplate/footerTemplate enable recurring headers and footers.
In production, browser pooling pays off because repeatedly starting and stopping Chrome processes creates unnecessary latency. Anyone who correctly includes fonts and images locally and waits for the page to fully load before calling page.pdf() gets a PDF generation with Puppeteer that matches exactly what's visible in the browser, without any compromise on Tailwind CSS's feature set.
For invoices and other accounting-relevant documents, it's also worth looking at pure PHP alternatives like Dompdf, as soon as a Node.js runtime is undesired in deployment. The basic principles for page breaks and print CSS remain transferable across both approaches.
PDF Generation with Tailwind CSS and Puppeteer — Key Takeaways
Why Puppeteer
Real Chrome renders the same Tailwind page as in the browser, including flexbox, grid, and custom properties.
Core function
page.pdf() with printBackground: true, margins, and paper format as the central export call.
Page breaks
break-inside-avoid and break-before-page control where table rows and sections break.
Performance
Reuse a browser instance instead of restarting per PDF, include fonts and images locally.