Guides · · 36 views

How to Save a JavaScript-Rendered Website as PDF

Save a JavaScript-rendered website as PDF

You try to save a modern web page as PDF and get back a blank sheet, a half-empty layout, or a frozen loading spinner. The page looks perfect in your browser — so why does the PDF come out empty?

It’s a timing problem. Most “URL to PDF” methods grab the page’s HTML before JavaScript has built the content. On an old-style server-rendered page that’s fine — the markup is already there. But a React, Vue, or Angular app sends a nearly empty shell and constructs the DOM in the browser afterward. Capture too early and you save the shell, not the page.

Here’s how to save a JavaScript-rendered website as PDF the right way — the methods that fail, the one that works, and the fixes for the edge cases that still bite.


Why the usual methods produce a blank PDF

  • wkhtmltopdf — the classic answer, fast and long-lived, but built on an ancient WebKit with effectively no modern JavaScript support. Static pages are fine; anything client-rendered comes out empty.
  • Fetching the raw HTML (an HTTP client piped into a PDF library) — same fatal flaw: no JavaScript runs, so there’s no rendered content to capture.
  • Browser Ctrl+P / “Save as PDF” — this does work, because it’s a real browser that already ran the page. But it’s manual, one page at a time, and can’t be automated cleanly. (It’s still the fastest option for a single page — see our guide to saving a website as PDF.)

What actually works is a real browser engine that runs the page’s JavaScript, waits for it to settle, and then prints. In code, that means a headless Chromium — most commonly via Puppeteer.


The Puppeteer approach (code)

Puppeteer drives a headless Chrome. It executes the page exactly like a normal 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 matter more than they look:

  • waitUntil: 'networkidle0' treats navigation as finished only after 500 ms with no network activity. For most single-page apps that’s the signal the data has loaded and the DOM is built. (networkidle2 allows up to two connections — useful for pages with analytics beacons or long-polling that never fully go quiet.)
  • printBackground: true — skip it and every background colour, gradient, and image vanishes, because Chrome’s print path drops them by default.

The gotchas that still catch you

Getting a non-blank PDF is step one. Getting a correct one is where the edges show.

Lazy-loaded content is missing

Images and sections that load on scroll won’t appear, because the headless browser never scrolled. Force them in first:

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);
  });
});

Text renders in the wrong font

Web fonts hadn’t finished loading at print time, so the PDF falls back to a system font. Wait for them explicitly:

await page.evaluateHandle('document.fonts.ready');

A print stylesheet wrecks the layout

If the site ships a @media print stylesheet, Chrome applies it — sometimes helpfully, sometimes destructively. Force the on-screen rendering instead:

await page.emulateMediaType('screen');

Fixed headers repeat or cover content

Sticky/fixed elements can duplicate on every PDF page or overlap the body. Neutralise them before printing — set their position to static, or hide them, via page.addStyleTag().


When rolling your own isn’t worth it

Puppeteer is the right tool when PDF generation lives inside a pipeline you control. But it has a real operational tail: a headless Chromium is heavy (200 MB+), needs the correct system libraries in your container, leaks memory if browser instances aren’t managed, and 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 tool does the same render-then-capture under the hood. Site2pdf.online renders each page in real Chrome, so JavaScript content comes through, and it can also follow a site’s internal links to capture many pages in one pass — exporting to PDF, PNG, or ZIP. For a full-site archive rather than one page, see how to archive a website.

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.


FAQ

Why does my JavaScript page save as a blank PDF?

Because the PDF was captured before the JavaScript finished rendering. React/Vue/Angular apps build the DOM in the browser after the initial HTML loads; tools that read the raw HTML (or use an old engine without JS support) capture the empty shell. Use a real browser engine and wait for the page to settle before printing.

How do I convert a React (or Vue/Angular) app to PDF?

Load it in a headless browser (Puppeteer/Playwright), wait with networkidle0 and document.fonts.ready, scroll to trigger lazy content, then call the PDF export with printBackground: true. Or use a hosted tool that does this for you.

Can I save a JavaScript website as PDF without coding?

Yes. A hosted service like Site2pdf.online renders the page in a real browser and exports the PDF — no Puppeteer, no server to maintain. It also handles single-page apps and lazy-loaded content that break simpler converters.

What’s the difference between wkhtmltopdf and Puppeteer for PDFs?

wkhtmltopdf uses an old WebKit build with essentially no modern JavaScript support, so it captures dynamic pages in their empty state. Puppeteer drives current headless Chrome and runs the page’s JavaScript, so it captures what actually renders. For anything client-rendered, use Puppeteer (or a Chrome-based service).


The takeaway

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. And if you’d rather skip the Chromium plumbing entirely, site2pdf.online renders each page in real Chrome and hands you the PDF.


Sources