One invoice, designed twice to look the same — once as a WebFluent template
(invoice.wf), once as the HTML and CSS a Puppeteer pipeline
would print (chrome/template.mjs,
chrome/invoice.css) — rendered from the same data
(invoice.json, 46 line items, 4 Letter pages)
with the same two font files (fonts/: Manrope and JetBrains Mono,
both variable TrueType). WebFluent on the left, Chrome on the right:
Measured on 2026-10-06. Every number below comes from one run of
just bench-pdf on the machine described; raw timings are in
results.json.
| Download | On disk | |
|---|---|---|
wf 5.3.0 (wf-v5.3.0-x86_64-linux.tar.gz) |
9.5 MiB | 22.8 MiB |
npm install puppeteer@25.12.0 (default: npm packages + Chrome for Testing + chrome-headless-shell 154.0.8037.57) |
305.7 MiB | 678.9 MiB |
| — of which Chrome for Testing | 187.1 MiB | 392.6 MiB |
| — of which chrome-headless-shell | 114.9 MiB | 261.4 MiB |
| — of which npm packages (puppeteer, qrcode and their dependencies) | 3.7 MiB | 24.9 MiB |
10 runs each, interleaved, after one priming run each.
| Median | Range | Peak memory (PSS) | Peak memory (RSS) | |
|---|---|---|---|---|
wf render (CLI) |
75.3 ms | 73.0 ms – 514 ms | 28.7 MiB | 31.4 MiB |
| Puppeteer + Chrome (default headless) | 581 ms | 570 ms – 598 ms | 424.1 MiB | 1164.0 MiB |
| Puppeteer + chrome-headless-shell | 395 ms | 366 ms – 415 ms | 273.3 MiB | 649.2 MiB |
The template (WebFluent) or the browser (Puppeteer) is set up once — the "setup" column, not counted in the renders — then 5 unmeasured renders, then 100 measured ones, one after another.
| Total | Median | p95 | Setup | Peak memory (PSS) | Peak memory (RSS) | |
|---|---|---|---|---|---|---|
WebFluent Rust API (Template::render_pdf) |
4.37 s | 43.4 ms | 47.4 ms | 0.8 ms | 27.4 MiB | 29.8 MiB |
| Puppeteer + Chrome, one tab reused | 9.00 s | 88.2 ms | 99.0 ms | 199 ms | 538.0 MiB | 1281.6 MiB |
| Puppeteer + chrome-headless-shell, one tab reused | 6.70 s | 66.8 ms | 70.5 ms | 69.7 ms | 427.5 MiB | 808.0 MiB |
| Puppeteer + Chrome, a new tab per render | 13.92 s | 140 ms | 148 ms | 202 ms | 758.9 MiB | 2700.0 MiB |
| Size | Pages | Line items on each page | |
|---|---|---|---|
| WebFluent | 41 KiB | 4 | 12 / 17 / 17 / 0 |
| Chrome | 360 KiB | 4 | 12 / 17 / 17 / 0 |
| chrome-headless-shell | 355 KiB | 4 | 12 / 17 / 17 / 0 |
Each is rounded in Chrome's favour and set against the fastest or smallest Chrome configuration measured, not the default one. Say "on this invoice, on this machine".
- Install: the
wfLinux download is 9.5 MiB; a defaultnpm install puppeteerdownloads 305.7 MiB (12.1× more thanwfeven counting only its smaller browser, chrome-headless-shell). - Cold render:
wf renderproduced the invoice PDF in a median 75.3 ms from process start, against 395 ms for a fresh Node process launching chrome-headless-shell through Puppeteer — 5.2× faster. - Warm renders: rendering the same invoice 100 times in-process took 4.37 s with WebFluent's Rust API (median 43.4 ms each) and 6.70 s with Puppeteer reusing one browser (median 66.8 ms) — 1.5× faster per render.
- Memory: a cold
wf renderpeaked at 28.7 MiB; Puppeteer and its browser processes together at 273.3 MiB (PSS). Warm, 27.4 MiB against 427.5 MiB. - Output: the same 4-page invoice is 41 KiB from WebFluent and 355 KiB from Chrome (see the caveat on fonts below).
Don't quote any ratio as true of PDFs in general: it is one document, and a different one would move it. Don't quote the cold numbers without "cold", or the warm ones without "warm".
- Same document. Both sides lay out the same data with the same fonts,
the same colours, sizes and spacing; each is styled explicitly so neither
leans on its engine's defaults. The WebFluent template uses
Document,Footer,WatermarkandQrCode; the HTML uses the CSS equivalents Chrome prints (@pagemargin boxes withcounter(page)/counter(pages), aposition: fixedwatermark, and theqrcodenpm package at the same error-correction level). The table header repeats on every page on both. - Same work per render. Each render starts from parsed data and ends
with the PDF bytes in memory (cold: written to a file). WebFluent's render
includes evaluating the template, reading and subsetting the fonts, layout
and writing the PDF. Puppeteer's includes building the HTML string
(template literal, number formatting, the QR code),
page.setContent(waitUntil: "load") andpage.pdf(); the fonts are inlined in the stylesheet as data URIs, read from disk once per process. - Cold is a fresh process each time (
wf render …/node chrome/cold.mjs), timed from spawn to exit by the runner, after one priming run so every side's files are in the OS page cache. The very first launch of Chrome after a reboot or install is much slower than this (seconds) and is not measured. - Warm follows the usual advice for Puppeteer on a server: launch the
browser once and keep it. Both tab strategies are measured. WebFluent's
side is a small Rust program (
rust/) that builds aTemplateonce and callsrender_pdfin a loop, with the release profilewfitself is built with. - Memory is measured in separate runs from the timings: the runner sums the RSS and the PSS of every process the command starts, every 10 ms, and keeps the peak (for a one-process command, GNU time's exact max RSS when it is higher). PSS splits a page several processes share among them, so Chrome's shared libraries count once; RSS counts them per process and overstates Chrome. Cold: the highest of three runs.
- Sizes.
wf: the GitHub release asset for the version measured, and its unpacked contents. Puppeteer: the Chrome for Testing archives it downloads, the packed npm tarballs its lock file names, and what all of it takes on disk afterwards. - The
wfmeasured is built from this checkout (b3aa5f95) withcargo build --release, the same code the release binary is built from.
| CPU | Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz, 16 logical CPUs |
| RAM | 30.6 GiB |
| OS | Ubuntu 26.04.1 LTS, Linux 7.0.0-34-generic |
| Load average (1/5/15 min) | before: 2.28 / 5.68 / 17.98; after: 2.75 / 4.84 / 16.42 |
| wf | 5.3.0 (b3aa5f95), rustc 1.99.0 (b940084d7 2026-09-28) |
| Node | v22.23.0 |
| Puppeteer | 25.12.0 |
| Chrome | Chrome/154.0.8037.57 (Chrome for Testing), HeadlessChrome/154.0.8037.57 (chrome-headless-shell) |
The machine was not otherwise idle: it is a desktop with a browser and editors open. The load average shows how busy it was; a quieter machine will give lower numbers on both sides.
- One document. An invoice: text, a long table, flex and grid boxes, borders, an SVG logo, a QR code. Documents heavy in pictures, or in CSS WebFluent's engine does not implement, would move the numbers, in either direction.
- Chrome does more. It is a complete browser engine: every CSS feature, JavaScript on the page, web fonts from anywhere, canvas, and years of print-path fixes. WebFluent's paged engine implements the CSS its documents need (block, flex, grid, tables, the box model, the cascade) and is much younger: it has had far less real-world exposure than Chrome's print path.
- Not pixel-identical. The two documents are as close as the two engines allow; see the picture above. They break pages in the same places (see "The documents") and wrap the same lines; small differences remain in the auto-sized table's column widths and a point or two of vertical spacing.
- The file sizes are partly about fonts. Chrome writes these variable
fonts as Type 3 fonts (outlines drawn per glyph, as
pdffontsshows); WebFluent embeds them as subset TrueType (CID) fonts. In a one-off side test with static font files (Liberation, on the Chrome side only) Chrome's file came out at about 200 KiB: fonts are part of the difference, not all of it. - Puppeteer adds a layer. The Chrome numbers include Node and the DevTools protocol round trips, as every Puppeteer user's do. A pipeline that talks to Chrome directly, or keeps a pool of browsers, could do better; one that opens a new tab per render (also measured) does worse.
- Parallelism is not measured. Every render here runs one at a time.
A server rendering many documents at once would use several threads with
WebFluent (a
TemplateisSend + Sync) or several tabs or browsers with Puppeteer. - WebFluent re-reads its font files on every render (from the OS page cache), so its warm numbers include that work.
just bench-pdfNeeds Rust (stable), Node >=22 and npm, and network access for the first
run (npm and Puppeteer's browser download, ~305.7 MiB).
Optional: pdfinfo/pdftoppm (poppler) for the page counts and the
picture, ImageMagick for the picture. Set BENCH_COLD_RUNS,
BENCH_WARM_RENDERS or BENCH_WARMUP to change the counts.
