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

Use a headless Chromium browser—Puppeteer or Playwright—to turn HTML and CSS into a PDF, then send the returned Buffer from an Express route. A reliable implementation reuses a browser process, creates a short-lived page for each request, waits for fonts and critical assets, chooses print or screen media deliberately, and applies bounded timeouts and cleanup.

Recommended architecture

Node.js does not render modern HTML and CSS into a PDF by itself. Puppeteer and Playwright automate Chromium, whose print engine understands web fonts, flexbox, grid, images and print CSS. Both expose a page-level page.pdf() method that returns PDF bytes. Puppeteer’s documentation describes Page.pdf() as the API for printing PDFs; Playwright documents the same buffer-oriented approach.

  1. Start one browser process when your service starts, or lazily on the first request.
  2. Create a new page for each document.
  3. Load a trusted template with page.setContent(), or navigate to an approved URL.
  4. Wait for network activity, fonts and critical images.
  5. Call page.pdf() with the required paper and styling options.
  6. Close the page in a finally block and send the Buffer with an explicit PDF content type.

Complete Express implementation with Puppeteer

Install Express and Puppeteer:

npm install express puppeteer

The following server keeps Chromium warm, creates an isolated page per request, validates input, waits for assets and returns binary PDF data.

const express = require('express');
const puppeteer = require('puppeteer');

const app = express();
app.use(express.json({ limit: '256kb' }));

let browser;

async function getBrowser() {
  if (!browser) {
    browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
  }
  return browser;
}

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&')
    .replace(//g, '>')
    .replace(/"/g, '"')
    .replace(/'/g, ''');
}

function renderReportHtml(data) {
  const title = escapeHtml(data.title || 'Report');
  const body = escapeHtml(data.body || '');
  return `

${title}

${body}
`; } app.post('/report.pdf', async (req, res, next) => { let page; try { const b = await getBrowser(); page = await b.newPage(); page.setDefaultNavigationTimeout(30000); page.setDefaultTimeout(30000); await page.setContent(renderReportHtml(req.body), { waitUntil: 'networkidle0' }); await page.evaluate(() => document.fonts.ready); const pdf = await page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true, margin: { top: '18mm', right: '16mm', bottom: '20mm', left: '16mm' } }); res.type('application/pdf').set('Content-Disposition', 'inline; filename="report.pdf"').send(pdf); } catch (error) { next(error); } finally { if (page) await page.close().catch(() => {}); } }); app.use((error, req, res, next) => { if (res.headersSent) return next(error); res.status(500).json({ error: 'PDF generation failed' }); }); const server = app.listen(process.env.PORT || 3000); process.on('SIGTERM', async () => { server.close(); if (browser) await browser.close(); });

Send JSON such as {"title":"Invoice 42","body":"Amount due: $125"} to POST /report.pdf. Express’s res.send() accepts a Buffer; res.type('application/pdf') sets the MIME type so browsers and clients handle the response correctly.

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

Playwright alternative

Playwright is a sound choice when your project already uses its test runner or needs its browser-install workflow. Install it with npm install express playwright, then replace the browser setup and PDF call:

const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await page.close();
await browser.close();

Both libraries can produce equivalent Chromium PDFs. Choose by deployment packaging, existing test stack, language support, observability and operational familiarity rather than assuming one has universally better output.

Control CSS media, colors and pagination

PDF generation uses the print CSS media type by default. That means rules inside @media print apply and screen-only interactions may disappear. For a screen-faithful rendering, call await page.emulateMediaType('screen') in Puppeteer, or await page.emulateMedia({ media: 'screen' }) in Playwright.

Printed colors can be adjusted by the browser. If exact backgrounds and colors matter, add:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }

Use print CSS for deliberate page breaks and document layout:

@page { size: Letter; margin: 0.7in; }
.keep-together { break-inside: avoid; }
.start-new-page { break-before: page; }
thead { display: table-header-group; }
@media print { .toolbar, button, .interactive-only { display: none; } }

preferCSSPageSize: true lets Chromium honor your @page size. Otherwise, the API’s format, width and height options determine the sheet. Specify margins explicitly instead of relying on defaults. Headers and footers can be enabled with Puppeteer’s displayHeaderFooter, headerTemplate and footerTemplate; keep their HTML self-contained because normal page styles do not automatically apply.

Make asset loading deterministic

Fonts

Puppeteer documents that page.pdf() waits for fonts by default, but explicitly awaiting document.fonts.ready makes the intent clear and is useful when other rendering steps are involved. Self-host fonts where possible and verify that the deployment can reach them.

Images and scripts

networkidle0 or Playwright’s networkidle can help, but analytics, websockets or long polling may prevent an idle state. In that case, use waitUntil: 'domcontentloaded' and then wait for a specific selector or image condition:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.waitForSelector('#chart-ready', { timeout: 10000 });
await page.evaluate(() => Promise.all(
  [...document.images].map(img => img.complete
    ? Promise.resolve()
    : new Promise(resolve => { img.addEventListener('load', resolve); img.addEventListener('error', resolve); }))
));

Use absolute URLs or data URLs for assets when setting content. Relative paths resolve against the document URL; if there is no base URL, they may fail. A <base href="https://approved.example/"> element can provide one, but only for origins you trust.

Efficiency, concurrency and reliability

Launching Chromium for every request is expensive. Keep one browser warm and open short-lived pages, while limiting simultaneous jobs to what your CPU and memory can sustain. There is no universal official throughput or memory figure: measure with your own templates, asset sizes, fonts, browser version and concurrency.

  • Use a queue or semaphore so a traffic spike cannot launch unbounded pages.
  • Set navigation and rendering timeouts; never wait forever for a third-party asset.
  • Close every page in finally, including error paths.
  • Restart a browser after repeated crashes or when your monitoring detects abnormal memory growth.
  • Log request duration, page errors, timeout type, browser version and output size without logging sensitive document contents.
  • Cache identical, immutable documents when business rules permit; include template and data versions in the cache key.

For public endpoints, require authentication, enforce request-size and rate limits, and queue expensive jobs. Treat supplied HTML and URLs as untrusted: arbitrary markup can trigger server-side requests or navigation to internal services. Prefer server-owned templates, validate data, restrict navigation to approved origins, and avoid exposing unrestricted JavaScript execution.

Troubleshooting common failures

PDF is blank or missing styles

Check that the HTML is complete, CSS URLs are reachable from the server, and external resources are not blocked by authentication or certificates. Capture browser console and request failures, then wait for the specific stylesheet or selector rather than relying only on a short delay.

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

Fonts or layout differ from the browser

The PDF uses print media by default. Add print rules, choose screen emulation when appropriate, await fonts, and run the same Chromium version in development and production. Verify that the intended font files load successfully.

Images are absent

Use absolute, permitted URLs; wait for image completion; and check content-security, authentication and certificate errors. Lazy-loaded images may require scrolling or triggering the page’s lazy-load mechanism before calling page.pdf().

Navigation or network-idle timeout

Long polling and analytics can keep the network busy. Switch to domcontentloaded, wait for a business-specific ready selector, and set a bounded timeout. Do not remove timeouts entirely.

Chromium fails to launch in a container

Install the browser dependencies required by your base image. Some restricted containers require the no-sandbox flags shown above; evaluate that choice against your deployment’s security policy rather than copying it blindly.

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

Memory rises under load

Limit concurrent pages, close pages promptly, reduce very large images, and recycle the browser after a measured threshold. Profile representative documents before selecting worker counts.

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 provides a one-call website screenshot and PDF API when you do not want to package Chromium yourself. It removes cookie banners, newsletter popups and chat widgets before capture; bot checks, blank pages and failed loads are never billed; and its MCP server lets AI agents such as Claude or Cursor take screenshots. The Free plan includes 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000.

For API details, see ScreenshotNeo’s documentation. A PDF request can target a page directly:

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

The endpoint can return PNG, JPEG, WebP or PDF according to the request options. It also supports full-page capture, CSS selectors, custom CSS and JavaScript, waits, headers, cookies, user agents, authentication, PDF paper settings and asynchronous jobs. Start with a free ScreenshotNeo account to use the 1,000 monthly shots without a card.

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

Node.js, Python and cURL request examples

If your service calls ScreenshotNeo from Node.js, use:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Python:

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)

Use the self-hosted Puppeteer or Playwright pattern when your PDF is generated from private, server-side data and needs complete template control. Use a hosted API when eliminating browser operations, consent overlays and failed-capture billing is more valuable than rendering inside your process.

Frequently Asked Questions

Should I use Puppeteer or Playwright for HTML-to-PDF generation?

Either is viable because both expose Chromium page PDF APIs. Decide using browser packaging, deployment compatibility, language support, observability and your team’s existing test stack.

Can an Express route stream a PDF?

Yes. Generate the PDF Buffer, set the response type to application/pdf, and send the Buffer. For very large or asynchronous jobs, store the result and return a download URL instead.

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.

Why is there no universal concurrency recommendation?

Rendering cost varies with template complexity, assets, fonts, Chromium version and machine resources. Benchmark representative documents in the environment where the service will run.

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.