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

> 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.

Source: https://sandboxer.com/guides/render-html-to-pdf-with-headless-chrome. Updated 2026-10-27.

**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](/guides/run-browser-use-or-playwright-with-real-chrome). For DOCX, XLSX and PPTX, see [LibreOffice headless](/guides/convert-documents-with-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:

```html
<!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:

```bash
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

```bash
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:

```bash
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.

```bash
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](https://playwright.dev/docs/api/class-page#page-pdf).

```javascript
// 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();
```

```bash
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.

```javascript
// 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`);
```

```bash
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](/guides/run-a-long-job-in-a-cloud-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:

```javascript
// 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](/docs/hosting) from your computer:

```bash
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](/guides/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](/guides/whats-preinstalled-in-a-sandbox).

## Related

- [Run Browser Use or Playwright with real Chrome in a cloud VM](/guides/run-browser-use-or-playwright-with-real-chrome)
- [Batch convert DOCX to PDF with LibreOffice headless](/guides/convert-documents-with-libreoffice-headless)
- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
- [Hosting docs](/docs/hosting)
