Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

If your HTML is rendered by JavaScript, generate the PDF in a real browser. Navigate with Puppeteer or Playwright, run page-context code with evaluate(), wait for a readiness signal owned by your application, then call page.pdf(). This preserves charts, asynchronously loaded data, web fonts and print CSS more reliably than converting the initial HTML string.

The browser workflow that produces complete PDFs

A dependable pipeline has five explicit phases:

  1. Load the document. Use page.goto() for a route or page.setContent() for an HTML string.
  2. Run setup JavaScript. Use page.evaluate() for code that should execute after the page is available. Use an init script such as Puppeteer’s evaluateOnNewDocument() when code must run before page scripts.
  3. Expose readiness. Have the application set a flag, dispatch an event, or render a known selector after data, charts and fonts are finished.
  4. Apply PDF media settings. PDF generation uses print CSS by default. Select screen media only when the screen layout is intentional for the document.
  5. Generate and inspect. Call page.pdf(), then check representative pages for clipping, missing backgrounds, incorrect page breaks and unloaded assets.

Puppeteer’s guide describes Page.pdf() as the API for printing PDFs. Its reference says PDF generation uses the print CSS media type. Playwright also returns a PDF buffer and uses print media by default.

Complete Puppeteer example with custom JavaScript

Install and run

Install Puppeteer in a Node.js project, save the following as render-pdf.mjs, and run it with node render-pdf.mjs. Replace the URL with the page that owns your report.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
npm install puppeteer
import puppeteer from 'puppeteer';

const url = 'https://example.com/report';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });

await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });

await page.evaluate(async () => {
  // This code runs in the browser, so window and document are available.
  document.documentElement.classList.add('pdf-export');

  // Ask the application to finish work that is specific to this export.
  if (typeof window.renderChartsForPdf === 'function') {
    await window.renderChartsForPdf();
  }

  // Wait for fonts and every image that is already in the document.
  if (document.fonts) {
    await document.fonts.ready;
  }
  await Promise.all(Array.from(document.images).map((image) => {
    if (image.complete) return image.decode ? image.decode().catch(() => {}) : Promise.resolve();
    return new Promise((resolve) => {
      image.addEventListener('load', resolve, { once: true });
      image.addEventListener('error', resolve, { once: true });
    });
  }));

  // Set this only after your data, charts and layout are ready.
  window.__PDF_READY__ = true;
});

await page.waitForFunction(() => window.__PDF_READY__ === true, {
  timeout: 30000
});

await page.pdf({
  path: 'report.pdf',
  format: 'A4',
  printBackground: true,
  preferCSSPageSize: true,
  displayHeaderFooter: true,
  headerTemplate: '<span></span>',
  footerTemplate: '<span class="pageNumber"></span> / <span class="totalPages"></span>',
  margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' }
});

await browser.close();

The function passed to evaluate() is serialized and executed in the page, not in Node.js. It can use browser globals such as window and document, but it cannot directly access Node modules, local variables or server credentials. Pass only serializable arguments when you need to provide data.

Rendering an HTML string instead of a URL

For generated markup, replace navigation with:

await page.setContent(html, { waitUntil: 'networkidle0' });

Use absolute URLs for stylesheets, images and fonts, or serve the assets from a reachable local origin. Relative paths without a meaningful base URL are a common reason a PDF has unstyled text or broken images.

Playwright implementation in Python

Playwright exposes the same essential sequence: navigate, evaluate in the page, wait for an application condition and call page.pdf(). This asynchronous example writes the returned bytes to disk.

pip install playwright
playwright install chromium
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page(viewport={"width": 1440, "height": 1000})
        await page.goto('https://example.com/report', wait_until='networkidle', timeout=60000)

        await page.evaluate('''async () => {
            document.documentElement.classList.add('pdf-export');
            if (typeof window.renderChartsForPdf === 'function') {
                await window.renderChartsForPdf();
            }
            if (document.fonts) await document.fonts.ready;
            window.__PDF_READY__ = true;
        }''')
        await page.wait_for_function('window.__PDF_READY__ === true', timeout=30000)

        pdf_bytes = await page.pdf(
            path='report.pdf',
            format='A4',
            print_background=True,
            prefer_css_page_size=True,
            margin={'top': '18mm', 'right': '14mm', 'bottom': '18mm', 'left': '14mm'}
        )
        await browser.close()

asyncio.run(main())

Playwright’s PDF method returns a buffer as well as supporting a path. Set emulate_media(media='screen') before generation only when you deliberately want screen rules; otherwise keep the default print media.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How to execute custom JavaScript predictably

Use page context for DOM work

Code such as chart initialization, hiding an interactive control, expanding a disclosure or replacing a live clock belongs in page.evaluate(). Return a small serializable result if the Node or Python process needs confirmation. Do not return DOM nodes or class instances; return strings, numbers, booleans or plain objects.

Use an init script for pre-load changes

If a site’s own JavaScript reads a value before your navigation completes, inject it before loading the page. In Puppeteer, call page.evaluateOnNewDocument(() => { ... }). This is useful for setting a deterministic timezone, stubbing a browser-only API or installing an early event listener. It is different from post-load evaluate(), which is appropriate after the document exists.

Make the page announce readiness

Prefer an application-owned signal over a fixed delay. For example, after the final API response is applied and the chart library has finished drawing, set window.__PDF_READY__ = true or add a data-pdf-ready="true" attribute to the report root. The automation can then wait for that exact condition. A timeout remains necessary as a safety limit, but it should not be the definition of completion.

Waiting for charts, fonts and asynchronous data

Charts and canvas output

Many chart libraries animate. Disable animation for export or expose a promise such as window.renderChartsForPdf() that resolves after the final frame. If a chart is drawn on a canvas, verify that the canvas dimensions are set before PDF generation; a zero-sized canvas can produce a blank area even when JavaScript ran successfully.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Data requests

Wait for the state transition that follows the last request, not merely for the network to become quiet. A page may open polling connections, analytics requests or image fetches after the report is visually complete. A selector such as [data-report-status="complete"] or an explicit readiness flag is more reliable.

Web fonts and images

Puppeteer’s documentation notes that PDF generation waits for fonts by default, but explicitly awaiting document.fonts.ready makes your intent clear and helps when fonts are loaded by application code. For images, wait for load or decode events and provide a failure path so one broken image does not hang the export forever.

Delays as a last resort

A short delay can accommodate a third-party widget with no readiness API, but it is inherently machine- and data-dependent. Keep it bounded and combine it with a selector or flag whenever possible.

Print CSS versus screen CSS

Both Puppeteer and Playwright use print media for PDFs by default. Define a print stylesheet for pagination and export-only changes:

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
@media print {
  .toolbar, .live-chat, .no-print { display: none !important; }
  .report { break-inside: avoid; }
}

@page {
  size: A4;
  margin: 18mm 14mm;
}

.chart, img { max-width: 100%; }

To reproduce the screen design instead, Puppeteer supports page.emulateMediaType('screen'); Playwright supports page.emulate_media(media='screen'). Print output colors may be adjusted for printing. Where supported, -webkit-print-color-adjust: exact requests the specified colors, although it can increase ink use and should be tested with your browser version.

PDF controls you should choose deliberately

Decision Available control Why it matters
Paper format such as A4 or Letter, or CSS @page Controls pagination and printable area.
Margins Top, right, bottom and left values Prevents headers, footers and content from colliding.
Backgrounds printBackground: true Includes colored panels and chart fills that browsers otherwise omit.
Headers and footers displayHeaderFooter, headerTemplate, footerTemplate Templates can include supported classes for date, title, URL and page number/total pages.
CSS page size preferCSSPageSize: true Lets an authored @page rule take precedence over a format setting.
Orientation Landscape option or a landscape @page rule Useful for wide tables and dashboards.

Troubleshooting missing or incorrect content

The PDF contains the loading skeleton

Cause: navigation completed before the application’s data promise. Fix: expose a post-render flag or completion selector and wait for it with waitForFunction() or waitForSelector(). Increase the timeout only after confirming the page eventually reaches that state.

Charts are empty or half drawn

Cause: animation, zero dimensions or a canvas drawn before its container was laid out. Fix: disable export-time animation, force layout, await the chart library’s completion callback and verify canvas width and height in evaluate().

Fonts fall back

Cause: the font URL is inaccessible to the browser, the font request is still pending or the page uses a relative URL without a base origin. Fix: serve the font over a reachable URL, wait for document.fonts.ready, and inspect browser console and request failures.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Colors or backgrounds disappear

Cause: print media rules or the default background omission. Fix: add print-specific styles, set printBackground: true, and use print-color adjustment only when the output requires exact colors.

Headers overlap the report

Cause: header/footer templates occupy space that the content margin does not reserve. Fix: increase the corresponding PDF margin and keep templates simple; template CSS does not inherit the page’s normal stylesheet.

evaluate() throws a serialization or reference error

Cause: the callback references a Node/Python variable or returns a non-serializable browser object. Fix: pass plain arguments explicitly and return plain data. Keep filesystem, secrets and database calls in the host process, not in page JavaScript.

The export times out or hangs

Cause: an awaited request, image or readiness flag never resolves. Fix: add bounded waits, resolve image errors, log failed requests, and make the application set an explicit failure state that the host can report.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Performance, reliability and cost considerations

Launching a browser is substantially heavier than manipulating an HTML string, so reuse a browser process and create a fresh page per job when your service handles multiple documents. Limit concurrency to what the host has memory and CPU for; validate the limit with your own documents because the cited documentation does not define a universal throughput or resource figure.

Cache browser binaries in deployment images, avoid waiting for analytics or advertising requests, and use a readiness signal instead of an unnecessarily long network-idle window. Record the URL, browser version, elapsed phases and failure reason for each job. For untrusted pages, isolate the browser, restrict network access where possible and never expose secrets through page globals.

There is no authoritative benchmark in the cited Puppeteer or Playwright documentation for accuracy, throughput or startup cost. Measure those values against your templates, fonts, chart libraries and deployment sandbox rather than applying a generic number.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo is a website screenshot API that can return a PNG, JPEG, WebP or PDF from one GET request. It accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; each cleanup step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For a one-call capture, use the API base shown below (replace the URL with your page):

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Its options include custom JavaScript and CSS, waits for selectors, delays or network idle, full-page capture with lazy images loaded, PDF paper size, margins, landscape and page ranges, plus custom headers, cookies, user agents, authorization, timezone and geolocation. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. See the ScreenshotNeo documentation for request options and PDF configuration.

The Free plan includes 1,000 shots each month with no card. Paid plans are Starter $5 for 3,000 shots, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free, and every feature is included on every plan. Create a free ScreenshotNeo account to start with 1,000 screenshots a month and no card.

FAQ

Can page JavaScript read files or environment variables during export?

No. Code evaluated in the browser has browser permissions. Read files, query databases and access environment variables in the host process, then pass only the required, non-sensitive data into the page.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should I use a PDF library instead of a browser?

Use a browser renderer when the source depends on layout, JavaScript, web fonts, canvas or CSS media rules. A non-browser PDF library can be appropriate for documents built from primitives, but it will not execute an application’s DOM code automatically.

How can I make failures diagnosable in a queue?

Save the document URL or template version, readiness state, browser console messages, failed-request URLs, elapsed navigation and render times, and the final exception. Retain a diagnostic screenshot or HTML snapshot only when your data-handling policy permits it.

Frequently Asked Questions

Can page JavaScript read files or environment variables during export?

No. Evaluated code runs in the browser context; perform file, database and environment access in the host process and pass only non-sensitive serializable data.

Should I use a PDF library instead of a browser?

Choose a browser renderer when JavaScript, DOM layout, web fonts, canvas or print CSS are required. Primitive, non-interactive documents may suit a non-browser library.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How can I make failures diagnosable in a queue?

Record the template or URL, readiness state, console and request failures, elapsed phases and the final exception; retain snapshots only if your data policy allows them.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.