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.

Run the HTML in a real browser engine, wait for your inline JavaScript to finish, and only then call page.pdf(). Puppeteer and Playwright both execute <script> blocks in a page context, so charts, fetched data, and DOM updates can appear in the PDF. A deterministic readiness flag or DOM marker is more reliable than an arbitrary sleep.

Use a browser engine, not a string-only converter

Libraries that only parse HTML into PDF instructions generally do not provide a JavaScript runtime. Your inline code may be copied into the document but never execute. Puppeteer and Playwright launch Chromium and expose the same browser environment your page expects: window, document, timers, fetch, layout, fonts and canvas.

The reliable sequence is:

  1. Launch a browser and create a page.
  2. Load the HTML with page.setContent() or navigate to a URL.
  3. Let inline scripts run and complete their asynchronous work.
  4. Verify fonts, images, charts and data are ready.
  5. Choose print or screen media, then call page.pdf().
  6. Close Chromium in a finally block.

Complete Puppeteer implementation

Install and convert an HTML string

Install Puppeteer in your Node.js project:

npm install puppeteer

This module accepts an HTML string and writes a PDF file:

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

export async function htmlToPdf(html, outputPath) {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();

    page.on('console', message => {
      console.log(`[browser:${message.type()}] ${message.text()}`);
    });
    page.on('pageerror', error => {
      console.error('Page JavaScript error:', error);
    });

    await page.setContent(html, { waitUntil: 'load' });
    await page.waitForFunction(() => window.__pdfReady === true);

    await page.pdf({
      path: outputPath,
      format: 'A4',
      printBackground: true
    });
  } finally {
    await browser.close();
  }
}

With an HTML file loaded from disk or a web server, use page.goto(url, { waitUntil: 'load' }) instead of setContent(). The load event covers document resources, but it does not know whether your application has finished fetching data or drawing a chart; that is why the explicit readiness condition matters.

Signal readiness from inline JavaScript

Put a flag in the HTML and set it only after every operation that affects the PDF has completed:

<!doctype html>
<html>
<head>
  <style>
    @media print { .no-print { display: none; } }
  </style>
</head>
<body>
  <h1 id="title">Sales report</h1>
  <div id="total">Loading…</div>
  <canvas id="chart" width="640" height="240"></canvas>
  <script>
    (async () => {
      try {
        const response = await fetch('https://example.com/data.json');
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        document.querySelector('#total').textContent =
          new Intl.NumberFormat().format(data.total);

        const canvas = document.querySelector('#chart');
        const context = canvas.getContext('2d');
        context.fillStyle = '#2563eb';
        context.fillRect(20, 20, Math.min(data.total, 600), 40);

        if (document.fonts) await document.fonts.ready;
        window.__pdfReady = true;
      } catch (error) {
        document.body.dataset.pdfError = error.message;
        console.error(error);
        throw error;
      }
    })();
  </script>
</body>
</html>

The Node process can fail fast on an error marker instead of silently printing “Loading…”:

await page.waitForFunction(() => {
  if (document.body.dataset.pdfError) {
    throw new Error(document.body.dataset.pdfError);
  }
  return window.__pdfReady === true;
}, { timeout: 30_000 });

A custom event is another useful contract when several components finish independently:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
// In the page
window.dispatchEvent(new Event('pdf-ready'));

// In Node.js, before loading the document
await page.evaluateOnNewDocument(() => {
  window.__pdfReadyPromise = new Promise(resolve => {
    window.addEventListener('pdf-ready', resolve, { once: true });
  });
});
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => window.__pdfReadyPromise);

The simpler flag approach is usually easier to inspect and troubleshoot. Do not make a fixed timeout your only readiness check: a fast machine wastes time, while a slow API or chart still races the PDF.

Inject JavaScript from Node.js

Run code after the document loads

When the script is not embedded in the supplied HTML, execute a function in the page context:

await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => {
  document.querySelector('#total').textContent = '42';
});
await page.pdf({ path: 'report.pdf', printBackground: true });

page.evaluate() runs where window and document exist. If the function returns a Promise, Puppeteer waits for that Promise, so this is valid:

await page.evaluate(async () => {
  const response = await fetch('/data.json');
  const data = await response.json();
  document.querySelector('#total').textContent = String(data.total);
});

Node variables are not automatically visible in the page. Pass serializable values explicitly:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const total = 42;
await page.evaluate(value => {
  document.querySelector('#total').textContent = String(value);
}, total);

Run code before the page’s own scripts

Use evaluateOnNewDocument() when you must install a value, shim or hook before any page script executes:

await page.evaluateOnNewDocument(() => {
  window.appConfig = { renderMode: 'pdf' };
});
await page.setContent(html, { waitUntil: 'load' });

For an external script, add a <script src="…"> element in the HTML or use the browser’s documented script-injection API. Keep secrets out of page JavaScript; anything injected into window can be read by the document.

Playwright equivalent

Playwright uses the same browser-context model. Its PDF method returns a buffer, which is convenient for an HTTP response or object storage:

import { chromium } from 'playwright';
import fs from 'node:fs/promises';

export async function htmlToPdf(html, outputPath) {
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage();
    page.on('console', message => console.log(`[browser:${message.type()}] ${message.text()}`));
    page.on('pageerror', error => console.error('Page JavaScript error:', error));

    await page.setContent(html, { waitUntil: 'load' });
    await page.waitForFunction(() => window.__pdfReady === true);
    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    await fs.writeFile(outputPath, pdf);
  } finally {
    await browser.close();
  }
}

Both tools evaluate page functions in the browser and use print CSS for PDF generation by default. Pick the one that matches the rest of your automation stack and the browser version management, authentication controls and diagnostics you already use.

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

Control print layout and media

Print versus screen styles

PDF generation uses the print CSS media type by default. If your screen stylesheet is the desired result, switch media before printing:

await page.emulateMediaType('screen');
await page.pdf({ path: 'report.pdf', printBackground: true });

Use @media print for page-specific changes such as hiding navigation. Background colors can be altered for print; preserve exact colors with:

* { -webkit-print-color-adjust: exact; }

PDF options worth setting deliberately

  • format: choose a paper size such as A4 or Letter.
  • printBackground: true: include CSS backgrounds and colored chart areas.
  • landscape: true: useful for wide tables and dashboards.
  • margin: set explicit top, right, bottom and left values when page geometry matters.
  • pageRanges: restrict output to selected pages for large reports.

Fonts are awaited by Puppeteer’s PDF operation by default, but application-specific images, data and chart libraries still need your readiness condition. If a chart changes layout after the flag is set, move the flag assignment until the final animation frame or disable animation for print.

Network, authentication and resource handling

The browser process must be able to reach every URL used by fetch(), images, fonts and scripts. A server-side URL may be private, blocked by a firewall or require cookies that your new page does not have. Supply authentication before navigation with the appropriate page request headers or cookies, and ensure the API permits the browser origin. CORS rules still apply to page JavaScript.

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

For deterministic reports, prefer same-origin data or embed the data in a JSON script element. If you use remote resources, log failed requests and set a finite readiness timeout. Never wait forever on a third-party endpoint.

page.on('requestfailed', request => {
  console.error('Request failed:', request.url(), request.failure()?.errorText);
});

await page.waitForFunction(() => window.__pdfReady === true, {
  timeout: 30_000
});

Validate or sanitize untrusted HTML before rendering. Browser JavaScript can make network requests, consume CPU and access any credentials you expose to the page. Run conversion in an isolated environment and avoid passing sensitive tokens through markup.

Common failures and precise fixes

Symptom Likely cause Fix
Inline script appears to do nothing A string-only converter was used, or the script threw. Use Puppeteer or Playwright; attach console and pageerror listeners and fix the first exception.
PDF contains “Loading…” page.pdf() ran before asynchronous work finished. Set window.__pdfReady = true after fetches and rendering, then wait with waitForFunction().
Wait times out The flag is never set, a request is blocked, or an exception stopped execution. Check browser errors, failed requests, CORS, authentication and the flag spelling; include a visible error marker.
Chart or image is missing Resource loading or drawing finished after readiness. Await the image’s decode(), the chart library’s completion callback, or a DOM marker before setting the flag.
Colors differ from the page Print media rules modify colors. Use emulateMediaType('screen') or -webkit-print-color-adjust: exact, and enable printBackground.
Node process hangs or leaks memory Browser instances are not closed after an exception. Launch once per controlled workload and always close in finally; limit concurrent pages.
Node cannot see a browser variable Node and page contexts are separate. Pass values as page.evaluate(fn, value) arguments or serialize them into the HTML.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance and reliability choices

  • Reuse a browser process for a batch, while creating isolated pages or browser contexts for separate jobs.
  • Keep readiness event-driven. A fixed delay is both slower and less reliable than a flag, event or DOM marker.
  • Disable chart animations and unnecessary analytics in PDF mode.
  • Set timeouts for navigation, data requests and readiness; report which stage failed.
  • Capture console output, page errors and failed requests in production logs.
  • Write the PDF only after page.pdf() resolves, and close pages and browsers on both success and failure.

There is no authoritative speed or memory figure that applies to every inline-script workload. Document size, browser version, fonts, images, network latency and chart complexity dominate, so measure your own templates rather than relying on a generic benchmark.

Or skip the browser setup

ScreenshotNeo is a website screenshot API that can also return a PDF from one GET request. It accepts cookie or consent banners as a visitor and 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 response headers identify the page verdict and whether the request was billed. Its MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients.

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

For a URL you control, the PDF call is:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o report.pdf

See the ScreenshotNeo documentation for PDF options and authentication. The service includes full-page capture, waits for a selector, delay or network idle, custom JavaScript and CSS, cookies and headers, geolocation and timezone controls, page ranges, signed webhooks for async jobs, bulk capture and a usage API. Every feature is on every plan. The Free plan includes 1,000 shots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to try it.

FAQ

Can inline JavaScript access Node.js modules?

No. Inline code runs in the browser page, not in Node’s module environment. Exchange only serialized values or deliberately exposed APIs.

Should I use a custom event or a readiness flag?

Either works. A flag is easiest to inspect with waitForFunction(); an event is convenient when independent components report completion. Set one only after all PDF-affecting work is complete.

Why does a browser PDF look different from a screenshot?

PDFs use print media and pagination, while screenshots use viewport rendering. Explicitly choose media, paper size, margins and background handling for the output you need.

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

Can I return the PDF without writing a file?

Yes. Playwright’s page.pdf() returns a buffer; Puppeteer can also return PDF bytes when no output path is supplied. Send that buffer from an HTTP route or store it in your preferred object storage.

Frequently Asked Questions

Can inline JavaScript access Node.js modules?

No. Inline code runs in the browser page, not in Node’s module environment. Exchange only serialized values or deliberately exposed APIs.

Should I use a custom event or a readiness flag?

Either works. A flag is easiest to inspect with waitForFunction(); an event is convenient when independent components report completion. Set one only after all PDF-affecting work is complete.

Why does a browser PDF look different from a screenshot?

PDFs use print media and pagination, while screenshots use viewport rendering. Explicitly choose media, paper size, margins and background handling for the output you need.

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

Can I return the PDF without writing a file?

Yes. Playwright’s page.pdf() returns a buffer; Puppeteer can also return PDF bytes when no output path is supplied. Send that buffer from an HTTP route or store it in your preferred object storage.

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.