If you've ever tried to turn a modern web page into a PDF programmatically, you've probably hit the wall: the file comes out blank, half-empty, or frozen on a loading spinner. The page looks perfect in the browser, so what gives?
The answer is timing. Most PDF approaches grab the HTML before the JavaScript has rendered the content. On a server-rendered page that's fine — the markup is already there. On a React/Vue/Angular app, the server sends an near-empty shell and the browser builds the DOM afterward. Capture too early and you save the shell.
Here's how to do it properly.
Why the naive approaches fail
wkhtmltopdf is the classic Google answer. It's fast and it's been around forever, but it uses an ancient WebKit build with effectively no modern JavaScript support. For a static page it's fine. For anything client-rendered, it captures the empty state.
Browser window.print() / Ctrl+P works because it is a real browser — but it's manual, single-page, and impossible to automate cleanly at scale.
Hitting the raw HTML with an HTTP client (axios/fetch then pipe to a PDF lib) has the same fatal flaw as wkhtmltopdf: no JS execution, no rendered content.
What you actually need is a real browser engine that runs the page's JavaScript, waits for it to settle, and then prints. That's Puppeteer.
The Puppeteer approach
Puppeteer drives a headless Chromium. It executes the page exactly like a normal Chrome tab, so whatever renders on screen is what you capture.
const puppeteer = require('puppeteer');
async function pageToPdf(url, outPath) {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'], // needed in most containers
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
await page.pdf({
path: outPath,
format: 'A4',
printBackground: true, // otherwise CSS backgrounds/colors are dropped
margin: { top: '20px', bottom: '20px', left: '16px', right: '16px' },
});
await browser.close();
}
pageToPdf('https://example.com', 'out.pdf');
Two options here matter more than they look:
-
waitUntil: 'networkidle0'tells Puppeteer to consider navigation finished only when there have been no network connections for 500ms. For most SPAs that's the signal the data has loaded and the DOM is built. (networkidle2allows up to 2 connections — useful for pages with long-polling or analytics beacons that never fully go quiet.) -
printBackground: true— forget this and every background color, gradient, and image disappears, because Chrome's print path strips them by default.
The gotchas that still bite you
Getting a non-blank PDF is step one. Getting a correct one is where the edges show up.
Lazy-loaded content
Images and sections that load on scroll won't be in the PDF, because Puppeteer never scrolled. Force them in by auto-scrolling before you print:
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = 300;
const timer = setInterval(() => {
window.scrollBy(0, step);
y += step;
if (y >= document.body.scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve();
}
}, 100);
});
});
Web fonts not loaded yet
Text renders in a fallback font because the web font hadn't finished loading at print time. Wait for it explicitly:
await page.evaluateHandle('document.fonts.ready');
Print CSS overriding your layout
If the site ships a @media print stylesheet, Chrome applies it. Sometimes that's what you want; sometimes it nukes the layout. Force screen rendering with:
await page.emulateMediaType('screen');
Fixed headers repeating or covering content
Sticky/fixed elements can duplicate on every PDF page or overlap the body. The reliable fix is to neutralize them before printing — set their position to static (or hide them) via page.addStyleTag().
When self-hosting Puppeteer isn't worth it
Puppeteer is the right tool when you need PDFs inside a pipeline you control. But it comes with a real operational tail: a headless Chromium is heavy (200MB+), it needs the right system libraries in your container, it leaks memory if you don't manage browser instances carefully, and it falls over under concurrency unless you pool pages and cap parallelism. For a side feature — "let users export this page" — that's a lot of infrastructure to babysit.
If you just need the output and not the plumbing, a hosted service does the same headless-render-then-capture under the hood without the ops burden. I run site2pdf.online for exactly this — it renders each page in real Chrome (so JS content comes through), and it can also follow a site's internal links to capture multiple pages in one pass, exporting to PDF, PNG, or ZIP. Handy when the alternative is standing up and maintaining your own Chromium fleet.
Rule of thumb: if PDF generation is core to your product, self-host Puppeteer and own it. If it's a convenience feature at the edge, offload it.
Wrapping up
The blank-PDF problem is almost always a timing problem: capture happens before JavaScript renders. Use a real browser engine, wait for the network (and fonts) to settle, scroll to trigger lazy content, and enable printBackground. Do that and dynamic pages convert cleanly.
The full, copy-pasteable version of this — plus the fixed-header and multi-page handling — is worth keeping in a snippet somewhere. You'll reach for it more often than you'd expect.
Top comments (0)