Skip to content
Back to blog
engineering typst infrastructure

Keeping Three Typst Engines in Lockstep

pdfs.build Team

pdfs.build renders documents with Typst, the open source typesetting system, which we also expose directly through a Typst API. We have written about why a typesetting engine beats a headless browser for data-driven documents. This post is about the less glamorous part: we run Typst in three different places, in three different forms, and the product promise (“the preview you saw is the PDF you get”) only holds while all three agree on what Typst is.

Why three engines

Each one earns its place.

In the browser, as WebAssembly. The editor shows a live preview that recompiles as the template or its data changes. Doing that with a server round-trip per keystroke would be slow and expensive; doing it locally via the typst.ts WASM build makes preview latency a local matter. Your keystrokes compile on your machine.

On the API server, as a Node compiler. When you call the render endpoint, a Node-side Typst compiler produces the actual delivered PDF. This is the engine whose output customers put in front of their customers.

In a Rust service, as the native crates. Saves and publishes pass through a compile guard: a dry-run compilation in a separate Rust service using the native Typst crates. It is the gate that keeps a template that does not compile from ever reaching the published state.

Three engines, one language, and an invariant that is easy to state and easy to lose: they must all be the same version of Typst.

The drift

At one point they were not. A routine dependency roll-up bumped the server’s compiler on its own, and nothing complained. Some time later the fleet sat at three different versions at once: the publish gate on Typst 0.13.1, the browser preview on 0.14.2, and the server that renders the delivered PDFs on 0.15.0.

Every test suite was green, because each engine passes its own tests in isolation. Version drift between engines is invisible to per-engine testing by definition.

What it produces instead is a class of bug reports that all sound like ghost stories. The preview looks different from the downloaded PDF, but only for templates using a feature that changed between versions. Worse: the gate was the oldest of the three, which means the door was open to templates that pass the gate and then render differently in delivery, and templates using newer syntax that render fine but cannot get past the gate at all.

The traps that made it worse

Package versions are not engine versions. The browser stack comes from the typst.ts project, whose package versions do not match Typst versions: typst.ts 0.7.x embeds Typst 0.14.2, and 0.8.x embeds 0.15.0. Reading the package number tells you nothing you can act on. The reliable move is to ask the engine itself: compile #sys.version and read the answer. We now do exactly that when verifying an upgrade, on all three engines.

Dev and prod load the WASM from different places. In development the browser engine comes from node_modules. In production it is fetched from our CDN, because shipping multi-megabyte WASM through the app bundle is unkind. Which means a package-only bump works perfectly in every local environment and breaks only in production, when the new JavaScript glue meets the old binary on the CDN. The fix was to version the CDN path, so a mismatched upload is a loud 404 during rollout instead of a silent ABI mismatch at runtime.

The rules that came out of it

The postmortem produced rules that are boring on purpose:

  1. An engine bump is one change that touches all three engines. Never one alone. The automated dependency bot is not allowed to make this change by itself, because it can only see one package file at a time.
  2. Verify by asking, not by reading. After any bump, each engine compiles #sys.version and the three answers must match. Package manifests are hearsay.
  3. The binary ships with the bump. The CDN upload of the WASM build is part of the same change, on a versioned path, so a missing upload fails fast.

All three engines currently report 0.15.0.

None of this is a Typst complaint. The same discipline applies to anything you embed more than once: a database driver, a markdown renderer, a font shaping library. Run one thing in three runtimes and version alignment stops being a dependency detail and becomes an invariant of the product, worth a gate of its own. Ours just happens to be written in the same language it is guarding.

Back to blog