Benchmarks
Render times, how they were measured, and where the time goes.
React documents
@inkoxide/render on two documents, an invoice styled with Tailwind classes and a 43-page
report, on the server and in the browser. The documents are in
benchmarks/documents. Apple M4 Pro (14 cores), 24 GB, macOS.
On the server
Node 24.14; median of 20 renders after a warm-up (10 for the report), in one process.
| Document | Pages | All cores | 1 thread |
|---|---|---|---|
| Invoice | 1 | 1.5 ms | 1.2 ms |
| Report: 40 sections, 1,000 table rows | 43 | 41 ms | 54 ms |
The first render in a new process, including loading the library, takes 166 ms for the invoice and 207 ms for the report (median of 5 processes).
In the browser
@inkoxide/render/browser, the engine as WebAssembly in a Web Worker, on a page built with Vite
and served from localhost. Median of 20 renders after a warm-up (10 for the report).
| Browser | Document | Pages | Render | Longest main-thread task |
|---|---|---|---|---|
| Chromium 153 | Invoice | 1 | 2.3 ms | none over 50 ms |
| Chromium 153 | Report | 43 | 56 ms | none over 50 ms |
| WebKit 26.6 | Invoice | 1 | 3.0 ms | not reported |
| WebKit 26.6 | Report | 43 | 47 ms | not reported |
- Main thread. The engine renders in its worker: what stays on the main thread (React, building the document tree) takes no task over 50 ms.
- First render. From the start of the page's navigation to the first invoice, the download of the library, its engine and its fonts included, takes 121 ms in Chromium and 116 ms in WebKit (median of 3 page loads). From localhost: on a real network, the download dominates.
- Download, with brotli. 1.76 MB: 1.18 MB of WebAssembly, 0.26 MB of JavaScript and 0.32 MB of fonts (Inter regular and bold). The module is downloaded once, and cached. Hyphenation patterns (1 to 350 KB a language) and Noto Sans, the fallback font, are downloaded only by a document that needs them; these documents need neither.
- Firefox was not measured: the Playwright build of Firefox does not start on this machine.
The documents and the harnesses are in benchmarks/:
pnpm install
scripts/build-node.sh
scripts/build-wasm.sh
pnpm --filter @inkoxide/benchmarks bench # on the server
pnpm --filter @inkoxide/benchmarks bench:browser # in the browser (pnpm exec playwright install first)
Engine
These numbers measure the engine alone, from a JSON document to PDF bytes, without React.
Headline. scripts/bench-headline.sh renders
fixtures/docs/report-100p-realistic.json (98 pages, 40 tables, two fonts, a header, and a
footer with page numbers) 10 times with the release CLI and prints the median:
| Machine | All cores | One thread |
|---|---|---|
| 4-vCPU Intel Xeon @ 2.8 GHz (cloud container) | 130 ms | 277 ms |
Larger documents. inkoxide bench on the same container (release, fat LTO, font
subsetting on):
| Document | One thread | 4 threads |
|---|---|---|
invoice: 1 page, median of 200 | 2.9 ms | 3.0 ms |
table-dump-500p: 435 pages, 20,000 rows, 27 MB of JSON | 2.59 s | 1.51 s |
First render in a process: invoice with 2 fonts | 5–8 ms | |
Peak RSS: table-dump-500p | 213 MB | 209 MB |
table-dump-500p is generated by scripts/fixtures/table_dump.py into target/generated/.
Times vary by about ±10% between runs on a shared machine.
Micro-benchmarks. cargo bench -p inkoxide --bench render runs the criterion
benchmarks in crates/core/benches/render.rs, one per fixture. render/svg-text renders an
SVG with 15 texts: each render lays the text out with its fonts, and reuses the SVG converted
with that layout from an earlier render. On an Apple M4 Pro it takes about 0.8 ms, against
0.6 ms for render/svg, a page of SVG images without text.
render/text-pretty sets three paragraphs of 87 words with textWrap: 'pretty', next to the
same paragraphs first fit. With those three paragraphs first fit as well, the page renders in
0.58 ms against 0.66 ms: pretty costs about 25 µs per paragraph (Apple M4 Pro, median of
criterion's samples).
Where the time goes
A single-thread profile of table-dump-500p. Wall times come from an instrumented build;
instruction shares from valgrind --tool=callgrind on a 2,000-row version of the same table,
counted inside render.
| Stage | Wall time | Share of instructions | Notes |
|---|---|---|---|
| Parse | 0.25–0.45 s | 18.7% | serde_json over 27 MB and 100,000 styled nodes; MessagePack input is faster |
| Validate | 0.05–0.09 s | ~2% | |
| Shape | 0.44–0.58 s | 26.5% | rustybuzz on every distinct word; repeated words come from a cache |
| Layout and pagination | 0.31–0.45 s | 5.4% | simple table cells skip Taffy entirely |
| Paint | 0.36–0.39 s | ~9% | display lists for 100,000 cells and their borders |
| Content streams | 0.15–0.19 s | ~6% | 19 MB of PDF operators |
| Deflate (level 6) | 0.49–0.58 s | 26.6% | 19 MB in, 2.6 MB out; compressionLevel trades size for speed |
On several threads, shaping, painting, content streams and compression run in parallel per page.
Synced from docs/BENCHMARKS.md in the inkoxide repository.