A reporting engine is easy to like at three pages. This post measures one at a thousand. The numbers come from one laptop and one definition, so read them as a single data point, not a promise about your hardware.
The setup
The definition is the statement template, unchanged except for one added group. It opens a group per month, and each group footer totals that month's charges and credits. The data is 35,000 account movements over three years, so the walk opens 36 groups. The next row always pays the invoice before it, which keeps the running balance a realistic width.
Rendered to A4, that's 1,005 PDF pages.
The machine is an Apple M1 Pro with 32 GB, on macOS 26.6.2 and Node 24.18.0. The engine is quario 0.14.0. The targets are @quario/html 0.13.0, @quario/pdf 0.13.0, @quario/xlsx 0.12.0 and @quario/docx 0.5.0. The runs set no license key, so every page carries the evaluation watermark.
A bare pdf() draws in the base-14 faces. A second PDF run embeds TrueType faces, the way this site's own previews do.
Each target ran in its own fresh Node process. The process compiled the report once and rendered it once cold. Two warm-up renders followed, then seven timed ones:
import { pdf } from "@quario/pdf";
const paged = pdf({ page: { size: "A4" } });
await report.render(paged, data); // cold
await report.render(paged, data);
await report.render(paged, data); // two warm-ups
const times: number[] = [];
for (let i = 0; i < 7; i++) {
const start = performance.now();
await report.render(paged, data);
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
console.log(times[3]); // the median of seven
Peak memory comes from a separate fresh process that rendered once and read its maximum resident set size. That figure includes Node itself and the loaded packages, which came to 80–105 MB before the render started.
The results
| Target | Median warm render | Cold render | Peak memory | Output |
|---|---|---|---|---|
| HTML | 408 ms | 626 ms | 183 MB | 10.2 MB |
| PDF, base-14 | 3.0 s | 3.6 s | 827 MB | 2.7 MB |
| PDF, TrueType | 12.0 s | 11.5 s | 872 MB | 3.4 MB |
| XLSX | 991 ms | 1.2 s | 353 MB | 1.2 MB |
| DOCX | 1.9 s | 2.5 s | 828 MB | 1.2 MB |
The TrueType PDF runs to 1,103 pages rather than 1,005, because its faces measure differently. The DOCX time covers writing the file only. @quario/docx is a flow target, so Word decides where the pages break when someone opens it.
Where the time goes
A CPU profile of the base-14 PDF render splits it roughly in three:
- About a quarter is in
@quario/layout, which measures text and decides where every line and page break falls. - About two-fifths is in writing the file:
@cantoo/pdf-lib, plusfflate, the compressor it depends on. - About a fifth is native time, garbage collection included. The engine's own walk over the rows, expressions and aggregates included, is under a tenth.
Embedding TrueType changes the picture. fontkit, the font library, takes close to two-thirds of that render. @quario/layout calls it while measuring text, and @cantoo/pdf-lib calls it again while encoding text into the file. That accounts for most of the gap between three seconds and twelve.
What scales and what doesn't
Rendered at sizes from 1,000 to 70,000 rows, three of the targets grow in a straight line. HTML holds at 12–16 ms per thousand rows, XLSX at 28–39 ms and DOCX at 54–67 ms.
The PDF target grows a little faster than its page count:
| Rows | Pages | Median render | Per page |
|---|---|---|---|
| 1,000 | 37 | 91 ms | 2.5 ms |
| 10,000 | 291 | 797 ms | 2.7 ms |
| 35,000 | 1,005 | 3.0 s | 3.0 ms |
| 70,000 | 2,005 | 7.2 s | 3.6 ms |
At 2,000 pages, each page costs about 45% more than it does at 37. The data doesn't show which part of the render that growth comes from, so this post doesn't guess.
A long render doesn't hold the process hostage. The longest the event loop waited during a single 1,005-page render was 115 ms with base-14 faces, and 379 ms with TrueType.
Where it starts to hurt
Memory is the limit to watch. The PDF target returns the finished file as one Uint8Array, and a single 2,005-page render peaked at 1.5 GB. The DOCX target peaked at the same height for the same rows.
Three things help, and none of them need anything from the engine:
- Split the document where a reader would. A statement per quarter is easier to read than one for three years, and each render peaks lower.
- Choose fonts on purpose. If the document is Latin text, the base-14 faces render four times faster here than embedded TrueType.
- Render off the request path. A three-second render belongs in a job queue rather than in a request a person waits on.
This post doesn't compare quario against a headless browser printing the same HTML. A fair comparison needs its own careful setup, and a quick one would only add a number nobody should trust. The JavaScript PDF generator guide compares how each approach works instead.
Top comments (0)