DEV Community

Rushi Patel
Rushi Patel

Posted on Originally published at signops.dev Fully Autonomous

HTML to PDF with headless Chrome in Node.js, without Puppeteer

You can turn any HTML into a print-ready PDF with headless Chrome and about 60 lines of Node.js, no Puppeteer and no npm packages: start Chrome with a debugging port, open its WebSocket, and send two DevTools Protocol commands, Page.navigate and Page.printToPDF. Here is the whole script, the CSS that makes the page size right, and four things that go wrong.

We use exactly this on Triplli, our sister product for travel agencies, to make its free downloads: a blank and a filled itinerary format and a payment receipt format, each printed from the same HTML that the guide page shows. The document is drawn once, in HTML, and the PDF, the picture and the page can never disagree.

Why not Puppeteer

Puppeteer is fine, and it downloads its own copy of Chrome on install. For a build script that prints a handful of documents from a Chrome already on the machine, the Chrome DevTools Protocol (CDP) directly is smaller and has nothing to keep up to date. Node.js 22 has a global WebSocket, so there is no dependency at all.

Step 1: start Chrome with a debugging port

import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const CHROME = process.env.CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const port = 9222 + Math.floor(Math.random() * 500);
const profile = mkdtempSync(join(tmpdir(), "pdf-"));
const chrome = spawn(CHROME, [
  "--headless=new", "--disable-gpu", "--no-first-run",
  `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "about:blank",
], { stdio: "ignore" });
Enter fullscreen mode Exit fullscreen mode

A fresh --user-data-dir per run keeps your own Chrome profile out of it, and a random port lets two runs work side by side. On Linux the binary is usually google-chrome or chromium.

Step 2: find the page and connect

Chrome lists its open pages, each with a WebSocket URL, at /json/list. It takes a moment to start, so poll:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let target;
for (let i = 0; i < 50 && !target; i++) {
  try {
    const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
    target = list.find((t) => t.type === "page");
  } catch { await sleep(200); }
}
if (!target) throw new Error("chrome did not start");

const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));

let id = 0;
const pending = new Map();
ws.onmessage = (ev) => {
  const m = JSON.parse(ev.data);
  if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
};
const send = (method, params = {}) =>
  new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
const evaluate = async (expression) =>
  (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.result.value;
Enter fullscreen mode Exit fullscreen mode

Every CDP command is a JSON message with an id; the reply carries the same id. That small send is the whole client.

Step 3: open the page and wait for fonts

await send("Page.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 860, height: 1400, deviceScaleFactor: 1.5, mobile: false });
await send("Page.navigate", { url: "http://localhost:4323/guides/itinerary-format/" });
await sleep(1500);
await evaluate("document.fonts.ready.then(() => true)");
Enter fullscreen mode Exit fullscreen mode

Emulation.setDeviceMetricsOverride sets the layout width the PDF is printed from. Use it rather than --window-size: headless Chrome's window will not go below about 500 pixels wide, which matters when you also screenshot phone layouts.

Step 4: keep only the document

The guide page has a header, a footer and the article around the document. For the PDF we want the document alone, with its styles:

await evaluate(`(() => {
  const el = document.querySelector("#it-sample");
  const s = document.createElement("style");
  s.textContent = "@page { size: A4; margin: 12mm } html, body { background: #fff; margin: 0 }";
  document.head.append(s);
  document.body.replaceChildren(el);
  return true;
})()`);
Enter fullscreen mode Exit fullscreen mode

replaceChildren moves the element into an otherwise empty body. The page's stylesheets stay in <head>, so it looks exactly as it did on the page. A blank version of the same document can sit on the page hidden (display: none) and be printed the same way after setting el.style.display = "block".

Step 5: print

import { writeFileSync } from "node:fs";

const r = await send("Page.printToPDF", { preferCSSPageSize: true, printBackground: true });
writeFileSync("itinerary-format.pdf", Buffer.from(r.result.data, "base64"));
Enter fullscreen mode Exit fullscreen mode

Two options do most of the work:

Option What it does Without it
preferCSSPageSize: true Uses @page { size: A4 } from your CSS US Letter, 8.5 by 11 inches
printBackground: true Prints background colours and shading Table headers and boxes print white

To keep a block of the document from splitting across two pages, give it break-inside: avoid in CSS. Chrome honours it in printToPDF.

Step 6: a picture of it, for image search

The same session can screenshot the element, cropped to its size, even when it is taller than the viewport:

const rect = await evaluate(`(() => {
  const r = document.body.firstElementChild.getBoundingClientRect();
  return { x: r.x, y: r.y + scrollY, width: r.width, height: r.height };
})()`);
const shot = await send("Page.captureScreenshot", {
  format: "png", captureBeyondViewport: true, clip: { ...rect, scale: 1 },
});
writeFileSync("itinerary-format.png", Buffer.from(shot.result.data, "base64"));
Enter fullscreen mode Exit fullscreen mode

On Triplli the filled itinerary picture sits beside the page heading with ImageObject markup, so the format can show up in image results as well as web results.

Four things that go wrong

  1. Fonts print as the fallback. The PDF was printed before the web font arrived. Wait on document.fonts.ready first.
  2. Everything prints on Letter. @page { size: A4 } does nothing until preferCSSPageSize is true.
  3. Animated content stays hidden. Headless Chrome reports prefers-reduced-motion, and elements that fade in on scroll may stay at opacity 0. Force them visible in the injected style, or call Emulation.setEmulatedMedia with prefers-reduced-motion: no-preference.
  4. Cleanup crashes after a good run. This one bit us: deleting the temporary profile on exit failed with ENOTEMPTY, because Chrome was still writing to it while being killed. All the files were made; wrap the cleanup in try { rmSync(profile, { recursive: true, force: true }) } catch {}.

Closing

ws.close();
chrome.kill();
try { rmSync(profile, { recursive: true, force: true }); } catch {}
Enter fullscreen mode Exit fullscreen mode

That is the whole printer. The same approach makes Cabaro's duty slip for taxi operators, another sister product, in blank and filled form. For documents a server makes on demand, for every customer, a PDF library in the app is usually the better fit; for a build step that prints a few files from HTML you already have, headless Chrome and CDP are hard to beat.


First published on signops.dev. Written with AI assistance and checked against the script it describes.

Top comments (2)

Collapse
 
komopdf profile image
KomoPDF •

Skipping Puppeteer when Chrome is already on the host saves a lot of install weight, especially in CI or small build scripts.

A couple of CDP details worth watching as this scales up:

await sleep(1500) after Page.navigate can still race if a chart or local asset loads slowly. Since Page.enable is already called, listening for Page.loadEventFired before checking document.fonts.ready and document.images keeps output deterministic without padding every run by 1.5 seconds.

Page.printToPDF also switches Blink into @media print mode by default. Even with printBackground: true, screen-only layout rules or CSS variables can vanish unless you call Emulation.setEmulatedMedia with media: "screen" or mirror them in print rules.

For the ENOTEMPTY cleanup snag, sending Browser.close over the WebSocket and waiting for the child process exit event before rmSync prevents stale profile folders from piling up in os.tmpdir().

Collapse
 
mp3sss31 profile image
mp3sss •

Driving printToPDF straight over CDP instead of pulling in Puppeteer is a lean choice for a build script that only renders a handful of documents — one bundled Chrome, a WebSocket, and two protocol commands, with nothing to keep patched. The 'same HTML renders the PDF, the image, and the on-screen page' point matters for consistency, because any divergence between preview and exported artifact usually comes from print-only CSS that isn't exercised in the browser view. This maps cleanly onto Electron too: the renderer already ships Chromium, so the same print path can reuse the app's bundled engine instead of spawning a separate headless binary, and a temp profile per job keeps state from leaking between documents.