Skip to content
Guides · Recipes

How to Render HTML to PDF With Headless Chrome in a Sandbox

Updated October 27, 2026 .md

Short answer: Every Sandboxer VM has Google Chrome, so google-chrome --headless --print-to-pdf works right away. Add --virtual-time-budget so page scripts finish, and install fonts-noto-cjk for Asian text. For page numbers, margins and waiting on a selector, use Playwright's page.pdf(). One browser printed 50 pages in about 2 seconds.

Every output and timing on this page comes from one default sandbox (4 vCPU, 8 GB) on prod on 2026-10-27, with Google Chrome 155.0.8059.39 and Playwright 1.64.1. This page is about printing HTML. To drive a browser or watch it, see Run Browser Use or Playwright with real Chrome. For DOCX, XLSX and PPTX, see LibreOffice headless.

Results at a glance#

What we tried Result
--print-to-pdf, no flags 0.7 s, but the JavaScript-rendered table is missing and a date/URL header is added
--print-to-pdf --virtual-time-budget=5000 --no-pdf-header-footer 0.7 s, full table, no header
Japanese text, base image Empty boxes (no CJK font installed)
Japanese text after apt-get install fonts-noto-cjk Correct glyphs; install took 3.2 s
Playwright page.pdf() with no options Letter paper, even though the CSS asks for A4; backgrounds dropped
50 HTML files, one google-chrome call per file 28.5 s to 29.3 s
50 HTML files, one Playwright browser, one page 2.1 s to 2.3 s
Same, 4 or 8 pages at once 2.4 s to 3.2 s (slower for these small pages)
PDF service on a private HTTPS URL 401 without the token, 200 with it, 2.3 s per request

1. The test page#

The page sets A4 in CSS, has a line of Japanese, and builds its table with JavaScript 1.5 seconds after load, the way a dashboard that fetches data would:

<!doctype html>
<html><head><meta charset="utf-8"><title>Q3 report</title>
<style>
  @page { size: A4; margin: 15mm; }
  body { font-family: sans-serif; }
  th { background: #1e3a8a; color: #fff; }
</style></head>
<body>
  <h1>Q3 report</h1>
  <p lang="ja">東京オフィスの売上</p>
  <div id="chart">Loading…</div>
  <script>
    // Simulates a fetch: the table appears 1.5 s after load.
    setTimeout(() => {
      const rows = Array.from({ length: 60 }, (_, i) =>
        `<tr><td>Item ${i + 1}</td><td>${(i * 37) % 500}</td></tr>`).join("");
      document.getElementById("chart").innerHTML =
        `<table id="data"><tr><th>Item</th><th>Units</th></tr>${rows}</table>`;
    }, 1500);
  </script>
</body></html>

Copy a folder into the sandbox with tar over sbx exec -i, then install poppler-utils for pdfinfo, pdftotext and pdffonts, which you use to check the output:

sbx new --type default
id=sb_…    # the id that sbx new printed
tar cz -C pdf . | sbx exec -i $id "mkdir -p ~/pdf && tar xz -C ~/pdf"
sbx exec $id --timeout 300 "sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends poppler-utils"

The commands in steps 2 to 5 run inside the sandbox, in ~/pdf. Use sbx ssh $id, or wrap each one in sbx exec $id "…".

2. Chrome's command line, and the wait problem#

google-chrome --headless --print-to-pdf=cli-plain.pdf file://$PWD/report.html
# …ERROR:dbus/bus.cc:405] Failed to connect to the bus: …
# 21287 bytes written to file cli-plain.pdf
pdftotext cli-plain.pdf - | head -4
# 10/27/26, 10:45 AM
#
# Q3 report

Two problems. Chrome prints as soon as the page loads, so the PDF has Loading… where the table should be: one page, zero table rows. And it adds a header and footer with the date, title, URL and page number. The dbus lines are harmless log noise.

--virtual-time-budget lets the page's timers and network run before printing. Chrome fast-forwards timers in virtual time, so a 5,000 ms budget doesn't cost 5 seconds of real time. --no-pdf-header-footer removes the date and URL:

time google-chrome --headless --no-pdf-header-footer --virtual-time-budget=5000 \
  --print-to-pdf=cli-wait.pdf file://$PWD/report.html
# 58660 bytes written to file cli-wait.pdf
# real    0m0.679s
pdfinfo cli-wait.pdf | grep -E "Pages|Page size"
# Pages:           2
# Page size:       594.96 x 841.92 pts (A4)

Now the table is there, over 2 pages. The command line honored the CSS @page { size: A4 } and printed the blue table header. It has no flag to wait for a specific element, so pick a budget longer than your slowest page needs, or use Playwright (step 4).

3. Fonts: check before you trust the output#

The base image has 21 fonts and none covers Japanese, Chinese or Korean. The first PDF showed the Japanese line as a row of empty boxes. pdftotext still extracts the characters, so a text check won't catch it; render a page to an image (pdftoppm -png) or check fc-list :lang=ja, which printed nothing.

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends fonts-noto-cjk
# real    0m3.246s
fc-match sans-serif:lang=ja
# NotoSansCJK-Regular.ttc: "Noto Sans CJK JP" "Regular"
google-chrome --headless --no-pdf-header-footer --virtual-time-budget=5000 \
  --print-to-pdf=cli-cjk.pdf file://$PWD/report.html
pdffonts cli-cjk.pdf | tail -n +3
# AAAAAA+LiberationSans-Bold      CID TrueType  Identity-H  yes yes yes   4  0
# BAAAAA+NotoSansCJKjp-Regular    Type 3        Custom      yes yes yes   5  0
# …

The re-rendered page showed 東京オフィスの売上 correctly. Install your brand fonts the same way, or copy .ttf files into ~/.fonts and run fc-cache. Web fonts loaded with @font-face from a URL also need the page to wait until they've loaded, which is one more reason to wait properly before printing.

4. Playwright page.pdf(): wait for an element, set the paper#

Playwright uses the installed Chrome with channel: "chrome", so npm i playwright is all you install; there's no browser download. waitForSelector waits for exactly the element you need instead of guessing a time budget. Options are in Playwright's page.pdf() docs.

// pdf.mjs: wait for the JS-rendered table, then print with a page-number footer
import { chromium } from "playwright";

const browser = await chromium.launch({ channel: "chrome" });
const page = await browser.newPage();
await page.goto("file:///home/user/pdf/report.html");
await page.waitForSelector("#data");           // the content we need is there

await page.pdf({ path: "default.pdf" });       // no options
await page.pdf({
  path: "report-pw.pdf",
  format: "A4",
  margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
  printBackground: true,                       // keep the table header color
  displayHeaderFooter: true,
  headerTemplate: "<span></span>",
  footerTemplate: `<div style="font-size:9px;width:100%;text-align:center">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
});
await browser.close();
npm init -y && npm i playwright
time node pdf.mjs
# real    0m2.829s
for f in default report-pw; do echo == $f; pdfinfo $f.pdf | grep "Page size"; pdftotext $f.pdf - | grep "^Page [0-9]"; done
# == default
# Page size:       612 x 792 pts (letter)
# == report-pw
# Page size:       595.92 x 842.88 pts (A4)
# Page 1 of 2
# Page 2 of 2

The 2.8 seconds includes the page's own 1.5-second timer. Compare the two PDFs:

  • Paper size. With no options, page.pdf() printed on US Letter and ignored the CSS @page { size: A4 }. Pass format, or preferCSSPageSize: true to let the CSS decide.
  • Backgrounds. The default dropped background colors, so the blue table header was gone and its white text was barely visible. printBackground: true brings them back.
  • Header and footer. displayHeaderFooter with templates gives page numbers. Set a headerTemplate too, or you get the default date and title header. Leave room with margin, or the footer draws over your content.

5. Batch: one browser, not one Chrome per file#

We generated 50 two-page invoice HTML files and converted them three ways. Each row is two runs.

// batch.mjs: one browser, N pages working through a folder of HTML files
import { chromium } from "playwright";
import { readdirSync, mkdirSync } from "node:fs";

const [dir, out, n = "1"] = process.argv.slice(2);
mkdirSync(out, { recursive: true });
const files = readdirSync(dir).filter((f) => f.endsWith(".html"));
const t0 = Date.now();
const browser = await chromium.launch({ channel: "chrome" });

async function worker() {
  const page = await browser.newPage();
  while (files.length) {
    const f = files.shift();
    await page.goto(`file://${dir}/${f}`);
    await page.pdf({ path: `${out}/${f.replace(".html", ".pdf")}`, format: "A4" });
  }
  await page.close();
}
await Promise.all(Array.from({ length: Number(n) }, worker));
await browser.close();
console.log(`${readdirSync(out).length} PDFs, ${n} page(s), ${((Date.now() - t0) / 1000).toFixed(1)} s`);
node batch.mjs $PWD/in out-1 1
# 50 PDFs, 1 page(s), 2.3 s
node batch.mjs $PWD/in out-4 4
# 50 PDFs, 4 page(s), 2.5 s
node batch.mjs $PWD/in out-8 8
# 50 PDFs, 8 page(s), 3.2 s
Method 50 files Per file
google-chrome --print-to-pdf, one call per file 28.5 s to 29.3 s about 0.6 s
Playwright, one browser, 1 page 2.1 s to 2.3 s about 0.04 s
Playwright, one browser, 4 pages 2.4 s to 2.5 s
Playwright, one browser, 8 pages 2.9 s to 3.2 s
4 Node processes, one browser each 3.0 s

Starting Chrome is the expensive part, so a loop that launches it for every file is about 13 times slower. For these small static pages, extra pages or processes made things slower, not faster. Concurrency can help when each page spends its time waiting on the network or its own scripts, so measure with your real pages before you add it. The command-line and Playwright PDFs of the same invoice had identical text. For a long batch, start it with sbx exec --detach and check on it later, as in run a long job in a sandbox.

6. Optional: a small PDF service on a private URL#

If another program needs to send HTML in, run a tiny HTTP server that keeps one browser open. The ?wait= parameter takes a CSS selector to wait for:

// server.mjs: POST HTML, get a PDF back. One shared browser.
import http from "node:http";
import { chromium } from "playwright";

const browser = await chromium.launch({ channel: "chrome" });

http.createServer(async (req, res) => {
  if (req.method !== "POST") { res.writeHead(405).end(); return; }
  let html = "";
  for await (const chunk of req) html += chunk;
  const page = await browser.newPage();
  try {
    await page.setContent(html, { waitUntil: "networkidle" });
    // ?wait=<css selector>: also wait for content your scripts add later
    const wait = new URL(req.url, "http://x").searchParams.get("wait");
    if (wait) await page.waitForSelector(wait, { timeout: 15000 });
    const pdf = await page.pdf({ format: "A4", printBackground: true });
    res.writeHead(200, { "Content-Type": "application/pdf" }).end(pdf);
  } catch (e) {
    res.writeHead(500).end(String(e));
  } finally {
    await page.close();
  }
}).listen(3000, "0.0.0.0", () => console.log("listening on 3000"));

Start it in the background and give port 3000 a private HTTPS URL from your computer:

sbx exec $id --detach "cd ~/pdf && node server.mjs > server.log 2>&1"
sbx host $id 3000 --json
# {"access":"private","port":3000,"url":"https://olive-polar-cricket-3000.box.sandboxer.com?_token=…"}

URL=https://olive-polar-cricket-3000.box.sandboxer.com
TOKEN=…    # the _token value from the URL above; keep it out of logs
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST --data-binary @report.html "$URL"
# HTTP 401
curl -s -o out.pdf -w "HTTP %{http_code} %{size_download} bytes %{time_total}s\n" \
  -X POST --data-binary @report.html "$URL?_token=$TOKEN"
# HTTP 200 25433 bytes 1.014525s
curl -s -o out.pdf -w "HTTP %{http_code} %{size_download} bytes %{time_total}s\n" \
  -X POST --data-binary @report.html "$URL?_token=$TOKEN&wait=%23data"
# HTTP 200 28504 bytes 2.295052s

Without the token, the proxy answers 401 before your server sees the request. The faster 200 is the wait trap again: networkidle returned before the page's 1.5-second timer, so the PDF said Loading…, one page and no table. With wait=%23data (#data, URL-encoded), the PDF had both pages and the table. Three requests with the wait took 2.29 s to 2.31 s, round trip from our test machine, most of it the page's own timer.

The private URL's token works like a password. Keep it in a secret store and out of logs. Treat the HTML you accept as untrusted too: a page rendered here can fetch any URL the sandbox can reach.

What survives a stop and resume#

We ran sbx stop $id --wait (2.2 seconds), then sbx resume $id (1.3 seconds). The memory was restored: uptime didn't reset, the server kept its process IDs, and the next request through the same private URL returned the full PDF in 2.3 seconds. The fonts, node_modules and PDFs on disk were kept.

If a resume can't restore memory, for example on a host with a different CPU type, the sandbox boots from disk. Files and packages stay, but the Node server and its browser are gone, so start server.mjs again (or run it as a systemd service). See detect a cold boot after resume.

FAQ#

Why is my PDF missing content that shows in a normal browser?#

Chrome printed before your JavaScript finished. On the command line add --virtual-time-budget=5000 (or more). In Playwright, call page.waitForSelector() on an element that appears only when the page is ready, then call page.pdf().

Why does Playwright give me Letter paper when my CSS says A4?#

page.pdf() uses its own paper size unless you tell it otherwise. Pass format: "A4", or preferCSSPageSize: true to use the CSS @page size. The google-chrome --print-to-pdf command line used the CSS size in our test.

Why does Chinese, Japanese or Korean text come out as boxes?#

The sandbox image has no CJK font. Install fonts-noto-cjk with apt (3.2 seconds in our test), or copy your own fonts into ~/.fonts. Check by rendering a page to PNG with pdftoppm, since pdftotext extracts the text even when the glyphs are missing.

Do I need playwright install or a separate Chromium?#

No. Google Chrome is already installed, and channel: "chrome" makes Playwright use it. See what's pre-installed in a sandbox.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide