Articles

Performance

Measured rendering time, startup and memory across Node.js, Bun, browsers and WASI runtimes, with a ten-document corpus and downloadable samples.

By Palatine · Published · Updated

How long does it take to turn a document into a PDF? The useful answer includes the document, the machine and where the timer starts. We ran Palatine’s existing ten-document corpus on Node.js, Bun, Chromium, Firefox, Safari, Wasmtime, Wasmer and WasmEdge, with native C++ as a reference.

These are local measurements from Apple M4, 10 cores and 32 GiB of memory, on macOS ARM64. Each document is rendered as PDF and PNG. The HTML, font, dimensions and renderer binaries are fixed for the whole run. Download the samples, versions and file hashes.

Two questions, two clocks

JavaScript API: how quickly does an initialized renderer return the next document? We measure renderer.render() after 5 warm-up renders. Loading the HTML and font and creating the renderer happen before this timer. Each document and output format gets a fresh process or browser page, so a small invoice does not inherit the memory capacity of a previous book.

Standalone command: how long does it take to run a conversion from files? We measure the complete native or WASI process: runtime and instance startup, loading the font and HTML, rendering, and writing the result and warnings. Each sample starts a new instance. Wasmtime and Wasmer’s compilation caches are primed separately; WasmEdge’s AOT compilation is completed before any rendering measurements.

The API and CLI columns have different timing boundaries. A ratio between them would combine engine speed with startup and I/O. Compare runtimes within each group, then choose the boundary that matches your application.

A book, an invoice, or an image

The book fixture produces 106 PDF pages, with Knuth–Plass composition and widow/orphan handling. Its warm median is 280.1 ms in Node.js and 213.8 ms in Bun. The same fixture runs inside the browsers themselves: Palatine’s WebAssembly produces the file, without using the browser’s print engine.

A table-heavy report stresses pagination differently from a book. A photo album also pays for image decoding. Use the controls to inspect both these costs and memory use, rather than treating the book as a prediction for every template.

106 PDF pages. Median of 5 measured runs.

JavaScript APIs

Warm renderer.render() call. Initialization and asset loading excluded.

  1. Node.js 280.1 ms
  2. Bun 213.8 ms
  3. Chromium 232.1 ms
  4. Firefox 1,823 ms
  5. Safari 254 ms

Standalone commands

Complete process, including instance startup, file loading and output. Compilation cache primed; no persistent renderer.

  1. Native C++ 212.4 ms
  2. Wasmtime 409 ms
  3. Wasmer 389.8 ms
  4. WasmEdge AOT 378.6 ms

Each group has its own scale and timing boundary. Browser CPU/RSS and CLI linear memory are not measured. Linear memory is allocated WASM capacity, not total memory used by the process.

The full corpus

The corpus contains an invoice, a web page, a long article, a table-heavy report, an image album, a composition with advanced CSS, a short report, a book, a book with footnotes and a dictionary with running headers. Every value below is the median of 5 timed samples. Minimum, maximum and individual samples are in the download.

The existing corpus also includes PDF cases that emit engine warnings: Table-heavy report (1); Image album (1); Advanced layout (39). Their warning counts remain in the source data. These timings include the engine’s documented degraded behavior for those cases; they are not a claim of complete visual equivalence.

JavaScript APIs · warm render call · median milliseconds
DocumentPagesNode.jsBunChromiumFirefoxSafari
Invoice130.72914.37921
Web page14237.334.430245
Long article152.938.73626854
Table-heavy report28691.6528.25403,563874
Image album8240.9207.4219.51,849241
Advanced layout15323.3273.9280.52,770308
Short report2465.956.152.336078
Book106280.1213.8232.11,823254
Book with footnotes48113.692.290.4639116
Running-header dictionary42327.78.96815
Native and WASI · complete command · median milliseconds
DocumentPagesNative C++WasmtimeWasmerWasmEdge AOT
Invoice124.1114151.6150.1
Web page140.7151.8156.7179.1
Long article1104.8240226247.9
Table-heavy report28592.4975.8894.7884.1
Image album8169.1386.5380.5392.9
Advanced layout15206457.7433.2461.9
Short report2464.7179.7177.4194.3
Book106212.4409389.8378.6
Book with footnotes48108.4245.6240.8246.6
Running-header dictionary419.7111.2118.4138.5

Initialization and memory

A service that reuses its renderer pays the initialization cost once. A short-lived job pays it on every invocation. Here are the book’s initialization, first render and subsequent warm renders for the JavaScript APIs. Initialization starts at createRenderer() and includes loading the embedded module from disk or the local test server and compiling it. Browser launch and the initial import of the API wrapper happen outside this clock. The first render includes the font setup performed by the render call.

Book PDF · JavaScript API
RuntimeInit, msFirst, msWarm, msLinear memory, MiBProcess peak RSS, MiB
Node.js191.5511.8280.133.2440
Bun38.7424.8213.833.2374.7
Chromium83.4252.6232.133.2—
Firefox4111,7771,82333.2—
Safari5644825433.2—

Linear memory is the WebAssembly module’s allocated capacity. Process peak RSS includes the runtime, compiled code, JavaScript, buffers and WASM. For JavaScript it is the process lifetime peak through initialization, warm-up and measured calls; for CLI cases we report the median of the individual process peaks. It does not measure memory retained after cleanup. The browser APIs do not expose a comparable whole-process RSS or CPU counter, so those entries are left unmeasured. The explorer keeps these metrics separate.

CPU time is user plus system CPU consumed during the measured scope. It can exceed elapsed time when the runtime works on several threads. Short CLI CPU samples have a coarse clock resolution; a difference of a few milliseconds is not evidence of a faster renderer.

WASI and the cost of compilation

The standalone WASI module is 20.24 MiB, or 5.50 MiB with gzip. It uses wasi-sdk and standard WebAssembly exceptions. This build includes Skia’s full raster and SkSL paths and does not use LTO. The JavaScript build uses Emscripten, legacy WebAssembly exceptions and the smaller browser profile: 11.17 MiB of WASM, 3.93 MiB with gzip. The default JavaScript loader embeds that module in a 14.94 MiB file (5.29 MiB gzip).

These are different deployment profiles of the shared C++ renderer. Their timings describe those deployable builds; they do not isolate the effect of the runtime alone.

WasmEdge is measured above in explicit AOT mode. Its precompiled artifact is 41.44 MiB, including the original WASM. Compilation took 228.6 seconds on this machine and is excluded from the conversion times. The default interpreter is a separate execution mode.

For context, a separate 5-sample invoice diagnostic measured the interpreter at 1,730.8 ms for PDF, compared with 150.1 ms for the AOT command in the full run. This diagnostic covers the invoice only. A deployment should choose its execution mode explicitly.

The WasmEdge AOT documentation explains how the compiled artifact is produced. The source download records both artifact hashes and the exact execution profile.

Reproduce the measurements

The benchmark commit is ce2b5940. It uses the unchanged repository corpus and Noto Sans font. Documents with paginate enabled produce paginated PDF; continuous fixtures produce one page. PNG uses the first viewport. The renderer does not fetch network resources during these cases.

RuntimeMeasured versionProfile
Node.jsv22.23.4JavaScript API
Bun1.4.4JavaScript API
Chromium151.0.7922.34JavaScript API
Firefox153.0JavaScript API
Safari26.3JavaScript API
Native C++native C++ CLINative C++ CLI
Wasmtimewasmtime 50.0.1 (c307ec81d 2026-10-29)Cranelift, on-disk compilation cache
Wasmerwasmer 7.5.0Cranelift, on-disk compilation cache
WasmEdge AOTwasmedge-aot version 0.18.1ahead-of-time compiled; compilation excluded
# Build the Emscripten, native and WASI targets first.
# See docs/BUILD.md and docs/WASI.md in the repository.
export VCPKG_ROOT="$PWD/.cache/tooling/vcpkg"
bun run bench:runtimes -- --iterations 5 --warmup 5

# Optional diagnostic of the uncompiled WasmEdge interpreter:
bun run bench:runtimes -- --runtimes wasmedge --filter facture --iterations 5

Targets run sequentially on the same machine. Chromium and Firefox use Playwright’s headless browser builds; Safari uses Apple’s WebDriver with the real Safari application. Each output is checked outside the timing window: PDF page count, extracted Unicode text and fonts, and PNG decoding and dimensions. The PDF text and page count must agree across runtimes. Byte hashes are retained, including raster differences between build profiles.

All eight WebAssembly profiles produced byte-identical PNG files for this corpus. Native PNG bytes differ in 9 fixtures; those differences are retained in the data. The 90 PDF checks agreed on text and page count.

We repeated the native invoice, Bun image album and Firefox book with seven samples, using eight warm-up renders for the JavaScript APIs. The book’s Firefox PDF recheck was 1,798 ms. The tables retain the original five-sample medians; the follow-up samples are included in the download. Both bundled browser executables were verified as ARM64.

These results describe one Mac and this corpus. Browser assets are served over loopback; external network downloads, hosted API latency, concurrent requests, battery use and other operating systems are outside this study. Thermal state, browser scheduling and background runtime compilation introduce variation. Re-run representative documents on your deployment hardware before sizing a service.

Download the complete results · Browse the benchmark corpus · Rendering API documentation


All articles