Million PDFs: Building a Modern Document Infrastructure with Rust and Typst

2026-07-2823 min read

Most engineering teams treat document generation as a solved, boring problem until the day it stops working. Erik Steiger's argument in this talk is that the tooling the industry still relies on — Crystal Reports, LaTeX distributions, headless-Chrome screenshotting — was never engineered for the operational reality of a regulated business: millions of documents per day, auditable template versions, and the ability to reproduce exactly what a customer received. His thesis is that a modern typesetter (Typst) gives you the performance, and that the ideas developers already trust from Git and Docker — content hashing, manifests, tags, immutable bundles — give you the compliance and debuggability that the rendering engine alone cannot.

Steiger is a senior software and AI engineer with a mathematics background, a former technical co-founder of two AI startups, and a consultant who worked with companies in regulated industries. He presented this 38-minute, 47-second talk at InfoQ Dev Summit Munich 2025; InfoQ published the recording and transcript on June 29, 2026.

These notes report what Steiger presented. Where I add background that an intermediate engineer needs but the talk assumed, it is labelled as such.

What You Will Learn

  • Why legacy PDF pipelines fail on latency, memory cost, and reproducibility, using two concrete industry cases.
  • What a typesetter is, and how Typst differs from LaTeX and from browser-based rendering.
  • How a serverless Rust rendering architecture on AWS Lambda was assembled, and the latency, memory, and cost numbers Steiger measured.
  • Why template layout caching is the single biggest performance lever.
  • How content-addressable storage, manifests, and Merkle trees give templates Git-like versioning and Docker-like immutability.
  • How storing render inputs alongside template hashes turns a multi-hour production debugging session into a lookup.
  • Where the approach does not fit, based on the talk and its Q&A.

Two Failures That Motivated the Work

Steiger grounds the talk in two client engagements rather than in benchmarks.

The first was a bank, running, as he puts it, "legacy software and it actually still is COBOL running the show" for the client he worked with. Its PDF pipeline had become too slow: a customer buying a stock would wait days for the confirmation document to arrive. He stresses that this was not merely a poor user experience — it was not regulatorily permitted, and the German financial regulator eventually told the bank it could not continue. The bank's response was to consider moving the pipeline to the cloud with AWS Lambda. Steiger rotated off the project, but reports second-hand from colleagues that one to two years later the team was still trying to work out how to do PDF rendering in a clean pipeline, with an approach involving Java and a program compiled per template. His reaction — "this is going to be a mess" — is an opinion, and he is explicit that he has no current contact with the project. Treat this as an anecdote about organisational drag, not a measured comparison.

The second was manufacturing, and here the problem was not per-document latency but management and traceability. In a regulated plant, a truck cannot leave the facility without a correct weighing slip stating its tonnage and, for regulated cargo such as medical gas, the associated compliance data. When a certificate failed to print, the support workflow Steiger describes was:

  1. The truck driver is blocked and calls the service provider.
  2. Engineers jump through two VPNs to a remote desktop just to see whether it is a real error or a user error.
  3. They take a full backup of the production database — 5, 6, or 7 gigabytes.
  4. They download that backup back through the two VPNs.
  5. They restore it into a test environment.
  6. Because of how the system was built, printing a certificate required an order to exist, so they had to create a valid fake order.
  7. Only then could they attempt to reproduce the failure.

That sequence is the real target of the second half of the talk. Notice what the engineers actually needed: the JSON-shaped data that fed the document. Everything else — the VPNs, the multi-gigabyte backup, the synthetic order — was accidental complexity caused by the renderer being coupled to a live database.

Why the Incumbent Tools Hurt

Steiger walks through the three tools he kept encountering.

Crystal Reports originated in 1984. You connect it to a database with credentials, it fetches the schema, and you drag and drop fields from a sidebar onto the page. Steiger's complaints are operational rather than aesthetic. Adding a factory in a new country meant a translation pass performed by clicking every single element, copying the text out, translating it, and pasting it back — he describes this as horrible and mentally breaking. It is Windows-only, and not even ARM-friendly: he could not get it running in a virtual machine on an Apple Silicon Mac. Crucially, it also requires an active database connection to function at all.

LaTeX is a typesetting system familiar to anyone from a scientific background. Steiger's framing for those who have not used it: think of LaTeX as a programming language that emits a PDF — you write markup for a section, a page break, or a mathematical symbol rather than manipulating a WYSIWYG canvas. Its weaknesses in a production pipeline are its size (the full distribution is around 5 gigabytes, with heavy dependencies, and the Docker image is correspondingly huge) and its error messages, which he describes as enormous and disorienting when a variable is wrong.

Puppeteer, and browser-based rendering generally, is the web-native answer: run headless Chrome, lay the document out with HTML and CSS, and print to PDF. The appeal is real — Steiger acknowledges that existing front-end talent and CSS knowledge transfer directly, whereas LaTeX requires learning a new formatting model. The cost is that you spin up an entire browser to produce one document. Memory consumption is high, and cold starts are long: on AWS Lambda he notes you need at least a second just to start the browser.

Coming from startup work where code lived in Git, environments were reproducible with Docker, and CI/CD was assumed, Steiger found document work to be "embrace the chaos and just spend four or five hours until it works." His explicit wishlist was:

Requirement Why it mattered
Speed A bank or broker may need to render a million PDFs by end of day
Low memory Booting a browser per document is not an efficient use of a machine
Version control Developers depend on it; without it you get "final version v3" filenames
Modern DX Syntax highlighting, cross-platform support, usable error messages
Data-only input You want to hand the renderer JSON, not a database connection

Typst: A Modern Typesetter

Typst is a typesetter developed by a team in Berlin, originating from a master's thesis. Background for readers new to the category: a typesetter is not a word processor. You do not manipulate the visual output directly; you write source that declares structure and content, and the engine computes the layout — line breaking, paragraph flow, page breaks, figure placement. LaTeX and Typst share this model; Word does not.

Typst's syntax resembles a Markdown-like DSL — equals signs introduce section headers, for example — which makes templates readable to engineers who have never used LaTeX. Steiger's practical points of comparison:

  • Size. Typst is a lean rewrite. The download is under 50 megabytes against LaTeX's roughly 5 gigabytes; you fetch it and compile a file to PDF.
  • Speed. He states it is faster than LaTeX for larger files.
  • Errors. Error messages are, in his experience, genuinely good, in contrast to LaTeX's walls of output.

The gap between Typst as shipped and Typst as document infrastructure is data injection. Typst's native model is a fixed source file compiled to a fixed PDF. Business documents want the opposite decomposition: an invoice should always look identical, while the name, invoice ID, and line items change per render. Steiger wanted something in between — a stable template plus per-render data — exposed as an ergonomic library call in whatever language the caller uses. In its simplest form the API is render(template, data), where the template is a string (in practice loaded from the filesystem or object storage) and the data is JSON.

The Serverless Rust Rendering Engine

Steiger's first artefact was a blog post and side project: Typst bundled into an AWS Lambda function, provisioned with Terraform, implemented in Rust. The key enabler was cargo-lambda, the tooling for building and packaging Rust-based AWS Lambda functions. He was explicit that the goal was narrow — demonstrate that something better than Crystal Reports was achievable — and that the prototype had no retry logic and no error handling.

The design used two Lambda functions:

  • The first is deliberately dumb: it accepts the HTTP request and pushes it onto an SQS queue.
  • The second does the work: it reads the queued request, fetches the template from S3, renders the PDF with the bundled Typst engine, and writes the result back into an S3 bucket.

The caller supplies the template name and the data, and receives only an acknowledgement that the render has been queued.

Architecture And Data Flow

flowchart LR
    C["Client request with template name and JSON data"] --> L1["Lambda 1 - enqueue only"]
    L1 --> Q["SQS queue"]
    Q --> L2["Lambda 2 - Rust with bundled Typst"]
    T[("S3 template storage")] --> L2
    L2 --> O[("S3 rendered PDFs")]
    L1 -.acknowledgement.-> C

Steiger later revisited this shape. Reddit commenters pointed out that the queue is not always necessary: you can drop the SQS hop and invoke the rendering Lambda directly through its Lambda function URL. Supplementary note: the queue still buys you back-pressure, batching, and a retry surface for genuine bulk workloads, so the choice depends on whether the caller wants a synchronous PDF or is submitting a large batch.

The Measured Results

The numbers Steiger reports for the serverless prototype:

Metric Reported result
Memory footprint Below 50 MB
Render latency, uncached Below 100 ms, i.e. two-digit milliseconds
Render latency, cached template Below 2 ms on a very small Lambda
Cost for one million PDFs Under 50 cents
Cost versus other approaches Roughly 20 times cheaper

The caching result is the most instructive part of the talk, and it is worth understanding the mechanism rather than the number. Typst takes a template with variables and computes a layout — how wide elements are, how words break, where lines fall. If the only change between two renders is a small substituted value such as a new number, the surrounding layout work does not need to be redone. By caching the compiled template and substituting only the changed region, Steiger gets renders below 2 milliseconds, because most of the work is already done.

He is candid that the prototype left performance on the table: uploads to S3 were performed one after another, and parallelising them would, by his estimate, cut another 30%.

Just as important as the speed was the developer experience the design produced. Typst templates are plain text, so you open them in your editor, you get sensible diffs in Git, and inputs are JSON, which is trivial to mock in a test. Steiger could compile the same program for his own machine and for the ARM-based Linux environment on Lambda.

Where the Engine Stopped Being Enough

Steiger explicitly says he was not happy, because raw speed did not address document management. The gaps he identifies:

  • On-premises deployment. A bank or financial institution may run its own hardware in the basement and will not — or, in his phrasing, perhaps should not — use AWS.
  • Template version control. Creating a Git repository per template is, in his words, definitely not recommended. An organisation may have hundreds of them.
  • Multi-file support. A template of 2,000 to 10,000 lines in one file is unmanageable; you want to split it and include images, logos, and fonts.
  • Debugging. Still poor, and the manufacturing workflow above was untouched.

An aside that generalises beyond documents: Steiger recommends publishing side projects publicly — Reddit in his case — precisely because strangers will find your mistakes. His example is that he did not know AWS Lambda provisioned concurrency bills continuously, not just while requests are being served. That is a real cost trap for anyone trying to eliminate cold starts on a bursty workload.

The Registry: Docker and Git Ideas Applied to Templates

For the second half, Steiger asked where to draw inspiration and landed on Docker Hub and package managers, because they enforce strict versioning. With Docker you can go beyond "the Python image" and pin an exact digest — a specific version, a specific layer. He wanted the same guarantee for documents.

Four design ideas follow from that.

Content-addressable storage. Instead of naming a file by what a human calls it, you hash the file's content and use the hash as its name. Background: this is exactly how Git stores blobs. The immediate practical benefit Steiger highlights is automatic deduplication — if many templates embed the same logo PNG, the identical bytes produce an identical hash, so the file is stored once rather than once per template.

Human-readable references. Nobody wants to memorise a hash, so the registry supports tags analogous to Git branches or Docker tags: invoice@latest, or invoice@v3 for the next rollout when the corporate design changes. Steiger stresses the regulated-industry requirement that sits underneath this: sometimes a team says "we have fixed the template," and you must be certain that this exact template is always used. So a specific immutable hash can also be pinned directly, bypassing the moving tag.

Bundles, not files. In this system, "template" means a bundle, not a single document. Logos, assets, and fonts are packaged together so the output is always identical. Why this matters: a PDF whose font resolves differently on a different host is a compliance problem, not a cosmetic one.

Library ergonomics. It had to remain a clean library so a server could later be built on top of it.

How a Bundle Is Stored and Resolved

The publishing flow: you POST to your template-management endpoint with a main entry point (for example main.typ), attached files, and metadata. What you get back is a hash representing everything you submitted.

Under the hood the registry writes a manifest file, named by that hash. The manifest is a JSON document that references each constituent file by its hash. That document is then itself hashed. Steiger notes this is the same construction Git uses and the same Merkle tree structure familiar from cryptocurrencies: a tree of hashes in which the root hash commits to every leaf. Consequence for the reader: if any byte of any asset changes, the leaf hash changes, so the manifest content changes, so the root hash changes. The identifier is therefore a tamper-evident fingerprint of the complete render environment, which is precisely the property an auditor wants.

Asking for a template whose hash starts with 39a9 means: fetch these files, assemble them into a bundle, and render. Resolution through a tag adds one indirection.

flowchart TD
    R["Reference invoice at latest"] --> H["Root hash"]
    H --> M["Manifest JSON"]
    M --> B1["Blob - main template"]
    M --> B2["Blob - logo image"]
    M --> B3["Blob - font asset"]
    B1 --> RND["Assemble bundle and render"]
    B2 --> RND
    B3 --> RND
    D["Per-render JSON data"] --> RND
    RND --> PDF["PDF output"]

Debugging Becomes a Lookup

This is where the manufacturing story closes. Because the system stores not only the template bundle but also the data used for each render, a support engineer can query renders by a business identifier such as the order ID. The failed render entry surfaces the render ID, the template reference, and a hash of the input data. Steiger's illustrative diagnosis is that the operator probably forgot the expiry date.

Compare the two workflows directly:

Step Legacy manufacturing workflow Registry-backed workflow
Access Two VPNs plus a remote desktop Query the render log by order ID
Data acquisition Download a 5–7 GB production database backup Download the stored input JSON by hash
Environment setup Restore backup into a test environment None; templates are self-contained bundles
Test fixture Create a valid fake order Not required
Reproduction Attempt to recreate the failure Replay the exact template hash with the exact data

Steiger's point is that reproducing a failed render becomes deterministic: take the data that was used, download it, inspect it, and iterate until it is right.

Operational caveat not emphasised in the talk: storing every render's input data means storing potentially sensitive personal and financial information — weighing slips, trade confirmations, invoices. That store needs its own retention policy, encryption, and access control, and it becomes in-scope for data-protection regimes. Content addressing also makes deletion awkward, because deduplicated blobs may be referenced by many renders.

Layering the Full System

Steiger's closing framing is that the pieces stack:

  1. Rendering engine. The AWS serverless work was, in retrospect, only this layer: Typst wrapped in Rust, fast and cheap.
  2. Registry. Built on top using Docker Hub and package-manager techniques: template management, compliance guarantees, and analytics. He calls out the analytics need concretely — a large factory printing many documents wants to know which renders failed in the last week so it can investigate why.
  3. Server. With the registry underneath, a server is straightforward to build and can be scaled horizontally and vertically. It gets parallelisation that uses all CPU cores without much headache, strong caching that reduces latency further, and a modern JSON data interface.
  4. User interface. He shows this as a conventional UI — explicitly "pre-MCP," i.e. before you would consider exposing it to an AI agent — listing past renders, which failed and why, with the ability to download the PDF and inspect the data.

Against the incumbents, the resulting bundle is under 100 megabytes, versus a LaTeX Docker image built from a 5 GB distribution. Unlike Crystal Reports, it needs no live database connection — just a template and some data. Unlike Puppeteer, it does not pay a browser's memory footprint or its cold-start cost.

Steiger's broader observation is that Typst was built for scientific papers, and its performance makes it viable for industrial document generation; combining it with tooling ideas from Docker, Git, and version control is what adds the compliance guarantees.

Licensing: everything he presented is open source, including the underlying Typst rendering engine. His own work is split into three crates: the main library built around Typst, the registry that handles manifests and hashing, and a comparatively thin server layer on top. Asked directly in Q&A whether any part required a paid licence, he confirmed it did not.

Trade-offs And Limitations

The benchmark document is one realistic page, not a poster. An audience member pushed on this, noting that in their experience render time depends heavily on the number of images, vector graphics, and fonts — their example was a poster with six text blocks, seven images, and vector graphics. Steiger's benchmark template was designed as a trade confirmation for a bank: one page, a lot of text, some lines, some vector content, and a PNG in the top left. The changing data was randomized, the table had to be re-laid out on every render, and results sometimes ran to two pages. That is an honest single-page business document, but the sub-2-millisecond and million-PDF cost figures should not be assumed to transfer to graphics-heavy output.

No head-to-head benchmark against LaTeX was run. Asked directly, Steiger said no. He recalls from his own earlier use that the fastest he achieved was roughly 300 to 500 milliseconds, and he immediately qualifies the comparison: LaTeX is not one program but a family — XeTeX, LuaLaTeX — and a fair comparison would require heavy optimisation of each. He goes further and says that five years ago he would have built a PDF engine on LaTeX, and that he has seen it used alongside MATLAB at stock agencies. His objection is the compilation size and the error messaging, not raw capability.

The numbers are a personal side project, not a production case study. Steiger frames the serverless work as a side project to prove an opportunity existed. It had no retry or error handling. Nothing in the talk reports the system running a bank's production workload.

Cached-versus-uncached latency claims differ by an order of magnitude. Below 100 ms without caching, below 2 ms with it, and in one Q&A exchange "sub-10 millisecond." Which regime you land in depends on cache hit rate — which in turn depends on how many distinct templates you render and how long instances live.

You cannot go backwards from a PDF by similarity. An audience member asked whether, given a PDF, you could recover the template and the JSON. Steiger's answer has two parts. If you keep the PDF, or at least its hash, in your bucket and associate it with the manifest used, then yes — you can derive the template version and the input data, and he notes this is exactly what compliance scenarios want. But when the questioner pushed toward fuzzy matching — finding "the closest hash" for a visually similar PDF — Steiger was clear that this does not work. Any difference, even one that is not visible in the rendered output, changes the hash. Cryptographic hashing gives you exact-match identity, not visual similarity; those are different problems requiring different tooling.

Template expressiveness has limits, though probably not ones you will hit. Asked whether Typst is powerful enough for the highly specific invoice layouts customers demand, Steiger said he believes most PDF layouts are achievable — Typst supports graphs and images and is, in his assessment, quite powerful — with the qualifier "if it's not very exotic." The one concrete uncertainty he raised is animation: PDF supports it, and he does not know whether Typst does.

Volume, not per-document latency, is often the real constraint. An audience member working in Java Enterprise reported getting close to 100 ms per render using caching and avoiding VM startup, and when asked whether speed was the main problem answered no — the sheer amount of PDFs was. Steiger drew the parallel to the bank: the requirement is generating them all in time. This matters for how you evaluate the architecture: a horizontally scalable, low-memory, cheap-per-document engine addresses throughput even where single-document latency was already acceptable.

Migration cost is not addressed. The talk does not discuss porting an existing estate of hundreds of Crystal Reports templates to Typst, which for a real organisation is likely the dominant cost.

Practical Takeaways

  • Decouple the renderer from the database. The single most damaging property of the manufacturing system was that reproducing a document required production data and a synthetic order. Make the render input a plain JSON payload and most of that pain disappears.
  • Store the render inputs, not just the outputs. Persisting the input data alongside the template identifier is what converts debugging from archaeology into replay. Pair it with a retention and access policy.
  • Identify templates by content hash, and let tags point at hashes. Tags such as invoice@latest serve day-to-day use; pinned hashes serve audits and regulated flows where "which exact version produced this document" must be answerable.
  • Treat the template as a bundle. Ship fonts, logos, and includes inside the addressed unit so a render is reproducible on any host. Content addressing deduplicates shared assets for free.
  • Cache the compiled layout, not just the template text. This is where the order-of-magnitude gain came from. If your renderer recomputes the full layout for every document that differs only in field values, you are paying for work you already did.
  • Measure a document representative of your real output. If you render image- and font-heavy pages, benchmark those, not a text-and-table invoice.
  • Watch serverless billing modes. Provisioned concurrency bills continuously, independent of traffic — verify before using it to hide cold starts.
  • Question whether the queue earns its place. A direct Lambda function URL is simpler for synchronous single renders; a queue earns its keep for bulk submission, back-pressure, and retries.
  • Parallelise the I/O tail. Sequential uploads of finished PDFs cost Steiger an estimated 30% of achievable throughput. Rendering fast and then writing serially wastes the gain.
  • Publish prototypes for review. The provisioned-concurrency billing issue and the redundant queue both came from public feedback rather than from Steiger's own testing.

Key Terms

  • Typesetter — A system that computes document layout from structured source, rather than letting you manipulate the visual result directly. LaTeX and Typst are typesetters; Word is not.
  • Typst — A modern, open-source typesetter from a Berlin team, originating in a master's thesis. Under 50 MB to install, with Markdown-like syntax and, per Steiger, notably better error messages than LaTeX.
  • Crystal Reports — A report-design tool dating from 1984 that connects to a database, reads its schema, and lets you drag fields onto a page. Windows-only and dependent on a live database connection.
  • Puppeteer — A library for driving headless Chrome; used for PDF generation by rendering HTML/CSS and printing. Flexible but memory-hungry with cold starts of at least a second on Lambda.
  • cargo-lambda — Tooling for building and packaging Rust programs as AWS Lambda functions, including cross-compilation for the ARM Linux runtime.
  • SQS — Amazon Simple Queue Service, the managed queue used here to decouple request acceptance from rendering.
  • Lambda function URL — A dedicated HTTPS endpoint for a Lambda function, allowing direct invocation without an intermediate API or queue.
  • Provisioned concurrency — An AWS Lambda feature that keeps instances warm to avoid cold starts, and which bills continuously rather than per request.
  • Content-addressable storage — Storing data under a name derived from a hash of its content, which yields automatic deduplication and tamper-evident identity.
  • Manifest — A JSON document listing the hashes of every file in a template bundle; it is itself hashed to produce the bundle's identifier.
  • Merkle tree — A tree of hashes in which each parent commits to its children's hashes, so the root hash uniquely fingerprints the entire structure. Used by Git and by blockchain systems, and by this registry.
  • Reference / tag — A human-readable pointer such as invoice@latest or invoice@v3 that resolves to a specific manifest hash.
  • Bundle — The unit of versioning here: the main template plus its images, logos, and fonts, packaged so rendering is reproducible.
  • XeTeX / LuaLaTeX — Alternative TeX engines within the LaTeX family, cited by Steiger to explain why a single "LaTeX benchmark" would be misleading.

Steiger's most transferable lesson is not "use Typst." It is that a document pipeline has two distinct problems, and the industry's tools solve only the first badly. Rendering speed is an engine concern, and a lean typesetter in a compiled language solves it convincingly. But reproducibility, versioning, auditability, and debuggability are distribution concerns — and the software industry already built excellent answers to those in Git and in container registries. Borrowing content addressing, manifests, tags, and immutable bundles turns documents from opaque artefacts that appear from a black box into versioned, replayable outputs you can reason about years later.


Reference: Erik Steiger, Million PDFs: Building a Modern Document Infrastructure with Rust and Typst, InfoQ Dev Summit Munich 2025, published by InfoQ on June 29, 2026.