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 (0)