This is Part 2 of 2 β the architecture. Part 1 is the story: how a Zerodha blog post about generating 1.5M PDFs in 25 minutes turned into a product.
This post is the how: the actual architecture, and the one trick that mattered more than any other.
Most "PDF APIs" are a headless browser in a trench coat
If you've shipped PDF generation in production, you know the dirty secret: a lot of "PDF APIs" are a headless Chrome browser wearing a trench coat. You send HTML, a copy of Chromium renders a web page, and a screenshot-of-a-document comes back. It works beautifully in the demo. Then it meets real traffic.
Browsers were built to render interactive web pages, not to typeset documents at scale. Bending one into a document factory drags along a stack of problems:
- Cold starts and weight. Each render spins up (or holds open) a browser process measured in hundreds of megabytes.
- Output drift. A Chromium version bump can quietly shift your invoice layout by a pixel β or a whole page. "Works on my machine" is not a great property for a legal document.
- A wide attack surface. You're executing a full browser engine on input that frequently contains user-controlled data.
- Expensive concurrency. More throughput means more browser processes, which means more memory and a bigger bill.
- A permanent maintenance tax. Fonts, sandboxes, zombie processes, and crashes become your problem forever.
None of that is Chromium's fault. It's a phenomenal browser. It's just the wrong tool for printing a receipt.
The bet: a native engine on Typst
Cellystial compiles templates to PDF natively in Rust, using Typst β a typesetting system designed for documents, not web pages. There is no browser anywhere in the pipeline. A template plus a JSON payload goes in; a pixel-perfect PDF comes out. The request below is illustrative β use your own template id and fields:
curl -X POST https://api.cellystial.com/api/v1/generate \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"templateId": "invoice",
"data": {
"company_name": "Acme Inc.",
"client_name": "Globex Corp",
"items": [
{ "description": "Design retainer", "quantity": 1, "price": 3000 },
{ "description": "Development", "quantity": 40, "price": 50 }
],
"tax_rate": 5
}
}' \
--output invoice.pdf
That one architectural decision pays off everywhere:
- Deterministic output β the same input produces the same document, byte for byte. No environment drift.
- A tiny attack surface β removing Chromium eliminates an entire class of rendering-engine vulnerabilities.
- Predictable, low memory β no browser processes to babysit under load.
- Real typesetting β proper pagination, tables that expand to fit, crisp typography by default.
So far, so clean. But there's a wrinkle the marketing pages never mention.
The part nobody warns you about: warm fonts are everything
Here's the thing that ate the most of my time, and the part I'm proudest of.
Not all templates are equal in how much you trust them. First-party templates β the ones Cellystial ships and controls β are safe to run in-process, on the same heap as the HTTP server (Axum, in our case). They reuse a font cache that's already warm in memory, so a typical document renders in roughly 2β10 ms per page with sub-1 MB of overhead per concurrent render. Cheap enough to do thousands of times a second.
But the whole point of the product is that users author their own templates. And user-authored Typst is untrusted β it has to run in an isolated child process with hard resource limits (a memory rlimit, kill-on-drop, the works). You cannot let it share your server's heap.
The naive way to isolate it is to spawn a fresh worker process per render. I tried that. It's correct, and it's slow β because the in-process font cache can't cross a process boundary. Every fresh worker re-parses the entire font set on startup. On my laptop that's about 190 ms of pure font warmup before a single glyph is drawn. In production, with the full Noto set loaded for international coverage, it's much worse. Suddenly the "milliseconds, not seconds" promise evaporates β not because Typst is slow, but because you're paying font warmup on every single call.
The fix is a warm worker pool:
- A small set of long-lived worker processes is pre-spawned at startup. Each one pays the font-warmup cost exactly once.
- They then serve many renders over a framed
stdin/stdoutprotocol, so subsequent renders skip the re-parse entirely. - Isolation is fully preserved β each worker is still a separate process with a memory
rlimitand kill-on-drop. - It's cancellation-safe: a worker is owned by the in-flight render future. If that future is dropped (say, a per-render timeout fires), the worker is dropped too and
kill_on_dropterminates it β it's never handed back to the pool mid-render, so the framed protocol can't desync. - Workers are recycled after a fixed number of renders to bound memory growth, and any I/O or protocol error kills the worker instead of reusing it.
The result: you get the safety of one-process-per-untrusted-render with (almost) the speed of in-process rendering. Warmup is paid at startup and on recycle, not on the hot path.
The warm pool wasn't the whole story
Once the worker pool was warm, fonts stopped being the bottleneck. So I went looking for what was left, and found a dumb one: the same template got compiled again on every single render. Parsing the Typst program, building it, then checking the request data against the template's schema. None of that changes between requests for a template you render over and over, but I was paying for it every time anyway.
The fix is boring: keep the compiled template around and reuse it. The first render of a template does the parse-and-validate work, and every render after that skips straight to drawing the document. Same idea for a batch job, compile the template once, then render every row of data against it instead of recompiling per row.
One line I want to be clear about, because "cache" plus "fast" usually means someone is handing you a saved file: we never cache the final PDF. Every document is rendered fresh from the data in your request, every time. The caching only removes the repeated compile work that sits in front of the render. The render itself is still done live, which is the whole point, and it's why editing the data on the homepage demo actually changes the document instead of returning the same one.
What it looks like in numbers
Because the engine keeps its fonts warm and renders without a browser, a typical document comes back in single-digit to low double-digit milliseconds once warm β not the seconds you wait on a browser screenshot.
I'm deliberately not quoting a big "100Γ faster" multiplier here, because I haven't published a head-to-head benchmark I'd want a skeptical engineer to hold me to. The honest, defensible version is the one above: warm renders land in milliseconds, and the architecture is the reason β not a tuning project you graduate to later.
What's actually shipped
To keep this grounded in reality rather than roadmap:
- Visual drag-and-drop builder that compiles to Typst (with a raw-Typst escape hatch).
- REST API for single renders, plus batch/async generation with webhook callbacks.
- AES-256 password protection with granular permissions (print / modify / extract).
- Email + cloud delivery β generate and hand back a pre-signed link, or email the PDF on render.
- A sample template library (invoices, contracts, certificates, tickets) you can clone.
- An official n8n node for no-code workflows.
That's the real surface today. (More integrations are in progress; I'd rather under-promise here.)
Wrapping up
The short version: if your documents have outgrown HTML-to-PDF, that's a good problem β it means they matter. The browser was never the right tool to typeset them; a native engine is. Typst made the speed possible, and a warm worker pool made it possible safely, even for untrusted user templates.
If you missed it, Part 1 is the origin story behind all of this. And the product is live and free to try at cellystial.com β it has only few users as I write this, so genuinely: come find the edges and tell me where it breaks. β
Originally written for the Cellystial blog. If you build with PDFs, I'd love your sharpest feedback.












