---
title: "Puppeteer PDF generation, and when to skip the browser — quario"
description: "Generate a PDF with Puppeteer and page.pdf(): the options that matter, what Chromium costs in production, and the same document rendered with no browser."
url: "https://getquario.com/compare/puppeteer"
---

# Puppeteer PDF

Puppeteer drives a real Chrome, and `page.pdf()` prints whatever that Chrome can render. That’s
the appeal. Every CSS feature works, web fonts load, and a chart library that needs a DOM draws its
chart. A page your app already shows can become a PDF without a second layout. If you already
have the HTML, it’s the shortest path from a screen to paper.

The cost is that you now run a browser to make a document. This page covers how to generate a PDF
with Puppeteer properly and what that costs in production. Then it shows when a renderer without a
browser is the better trade.

## Generate a PDF with Puppeteer

Build the HTML, load it into a page, and print it. With `puppeteer` installed:

```ts title="statement-puppeteer.ts"
import { writeFileSync } from 'node:fs'
import puppeteer from 'puppeteer'

const html = statementHtml(await getStatement(id))   // your template, your escaping

const browser = await puppeteer.launch()
try {
  const page = await browser.newPage()
  await page.setContent(html, { waitUntil: 'networkidle0' })

  const bytes = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '20mm', right: '19mm', bottom: '24mm', left: '19mm' },
    displayHeaderFooter: true,
    headerTemplate: '<div></div>',
    footerTemplate: `<div style="width:100%;padding:0 19mm;font-size:8px;text-align:right">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
  })
  writeFileSync('statement.pdf', bytes)
} finally {
  await browser.close()
}
```

To print a page that’s already live, swap `setContent` for `page.goto(url)`. Either way, a few
defaults catch people out:

- **The paper is Letter** unless you pass `format`.
- **Backgrounds don’t print** unless `printBackground` is `true`, so table stripes and coloured bands
vanish.
- **There are no margins** unless you set them, and the header and footer draw inside the margins, so
a footer needs room at the bottom.
- **The header and footer stay off** until `displayHeaderFooter` is `true`. Each template is a
separate HTML string, styled inline, into which Chrome fills the `pageNumber`, `totalPages`, `date`,
  `title` and `url` classes.
- **The page prints with the `print` media type.** Call `page.emulateMediaType('screen')` first to
print what the screen shows.
- **The call times out after 30 seconds** by default. A long document on a busy machine can reach it.

Page breaks come from CSS. `break-before`, `break-after`, and `break-inside: avoid` on rows and blocks
steer where Chrome cuts the page. Getting a long table to break cleanly, with its total kept on the
same page as its last rows, takes tuning.

## What Chromium costs in production

Installing `puppeteer` downloads Chrome for Testing, around 282 MB on Linux, and a
`chrome-headless-shell` binary into a cache folder. `puppeteer-core` skips the download if you bring
your own browser. Either way, the browser comes with its own needs:

- **A container that can run Chrome.** The image needs its system libraries, plus fonts for every
script your documents print. Puppeteer’s own troubleshooting guide carries a Docker setup for this.
- **A browser process to manage.** Launch one per request and every document pays its start-up time.
Keep one warm instead, and the crashes, the memory, and the pages left open are yours to handle.
- **A browser to patch.** The Chrome in your image is a browser like any other, and its updates are
yours to ship.
- **HTML that’s yours to escape.** The document is a web page in a real browser. A customer name that
reaches your template unescaped is markup, and markup can load URLs.

None of this rules Puppeteer out. It’s the bill, and it’s worth reading before the first document
ships.

Moving the browser into a service, as Gotenberg does with Chromium behind an HTTP API, changes who
runs it rather than whether it runs.
[Gotenberg against a library in-process →](https://getquario.com/compare/gotenberg.md)

## The same statement, without a browser

quario renders a document from a **definition**, JSON you store, version, and review, bound to your
data at render time. `@quario/pdf` typesets the pages itself, so there’s no Chromium in the path. Here
is the same statement of account, from the [statement template](https://getquario.com/templates/statement.md):

```ts title="statement-quario.ts"
import { readFileSync, writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import statement from './statement.report.json' with { type: 'json' }

const logo = () => readFileSync('logo.png')          // your logo, as PNG bytes
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(statement, { logo })

const data = await getStatement(id)                  // your ledger
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('statement.pdf', bytes)
```

The Puppeteer version split the document across a template, a print stylesheet, and a footer
string. Here it sits in one place. The “Page 2 of 3” line is a `page.footer` band in the definition, reading `page.number` and
`page.total`. The running header that only appears from page 2 is a `page.header` band with
`"visible": "=page.number > 1"`. The movement table’s column labels repeat on every page, and the last
row keeps its totals with it, without a line of CSS.

Three more things change:

- **The output is deterministic.** The same definition and data produce a byte-identical file, so
you can cache a rendered PDF, hash it, or compare it in a test.
- **Data is never markup.** The target draws a customer name as text, so there’s nothing to escape
and nothing for it to load. Expressions parse into closures, with no `eval`, no `new Function`, and
no code generation. Even a definition a user or an AI agent wrote can’t run anything.
  [How that holds →](https://getquario.com/docs/diving-deeper/security.md)
- **One definition reaches four document shapes.** The statement renders to an HTML fragment, a PDF,
an Excel workbook, and a Word document. The export a customer downloads and the page they see never
drift apart.

## Keep Puppeteer for the typography

Some documents genuinely need the browser’s CSS. You can still take the report logic out of the
template. Render the definition to an HTML fragment with `@quario/html`, wrap it in your own print
stylesheet, and print that:

```ts title="statement-hybrid.ts"
import { quario } from 'quario'
import { html } from '@quario/html'
import puppeteer from 'puppeteer'

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const fragment = await report.render(html(), data)

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`<style>${printCss}</style>${fragment}`)
const pdf = await page.pdf({ format: 'A4', printBackground: true })
await browser.close()
```

The fragment carries a stable class contract, `.q-group`, `.q-table` and friends, for a print
stylesheet to hold on to. [HTML reports](https://getquario.com/guides/html-report-generator.md) covers it.

## When to keep Puppeteer

Puppeteer is the better choice when:

- **The PDF is a page you already render.** A dashboard, a ticket, or a marketing page your app
shows is already laid out. Printing it beats describing it twice.
- **The design needs CSS a report layout doesn’t have.** Grid layouts, web fonts loaded from a
stylesheet, and chart libraries that draw into the DOM all need the browser.
- **You already run browsers**, for end-to-end tests or screenshots, and one more job on that fleet is
cheap.
- **The budget is zero.** Puppeteer is Apache-2.0. quario is commercial past evaluation.

Reach for quario when the documents are data-driven, such as invoices, statements, payslips, and
reports. It earns its place when they also need to exist as a workbook or a Word file. It also fits
when Chromium is the heaviest thing in a container that otherwise needs none.

## FAQ

### Can quario generate a PDF in a cloud function or an edge runtime?

Yes. `@quario/pdf` is a JavaScript library with no browser behind it, so it runs wherever your code
runs, in Node, the browser, and edge runtimes. A function that renders a PDF doesn’t need a Chromium layer.

### Does quario convert HTML to PDF?

No. quario doesn’t read HTML. It renders a definition and your data straight to PDF. If you have an
existing HTML page to print, Puppeteer is the right tool for that page.

### Can I use quario for free?

Evaluating it’s free, with no time limit. You get the full engine and need no key, and evaluation
output carries a watermark. Shipping it in an application or running it in your business needs a
license for each production deployment. Its users, agents, and renders are never counted, and replicas
of one service count once. [See pricing →](https://getquario.com/pricing.md)

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Comparisons","item":"https://getquario.com/compare"},{"@type":"ListItem","position":3,"name":"Puppeteer PDF","item":"https://getquario.com/compare/puppeteer"}]}
```
