Companion benchmark + demo repository for the Emikra Labs article "Choosing Typst Over LaTeX for PDF Generation in DeedTracker."
This repository quantifies and demonstrates the tradeoffs discussed in the article: compile speed, engine/binary footprint, and output characteristics for resume-style PDF generation, comparing Typst against LaTeX (via Tectonic, the same engine DeedTracker used before migrating).
It is intentionally small and reproducible: a single Go binary drives the benchmarks, and a Docker Compose stack pins both engines so results are comparable across machines.
| Path | Purpose |
|---|---|
internal/bench |
Engine abstraction + benchmark harness (engine-agnostic). |
internal/cli |
Small helpers (repo-root discovery, fatal errors). |
cmd/bench |
CLI runner. Benchmarks locally or launches Docker. |
cmd/demo |
Generates a sample PDF from the Typst template (a real demo). |
templates/resume |
Vendored minimal.typ (from DeedTracker) + minimal.tex (LaTeX comparison) + raw.typ (static Typst, no injection). |
templates/sample/resume.json |
Shared resume data injected into the Typst template. |
Dockerfile, docker-compose.yml |
Reproducible, version-pinned engine images. |
- An
Engineinterface (internal/bench) hides the differences between Typst and LaTeX behind a singleCompile(ctx, outPath)method that returns the elapsed time. TypstEngineshells out totypst compile, injecting the resume JSON via--input resume=<json>(the template readsjson(bytes(sys.inputs.resume))).LaTeXEngineshells out totectonic, which performs the multi-pass compilation automatically (mirroring the old DeedTracker LaTeX path).TypstRawEnginecompiles a fully static Typst document (raw.typ) with no JSON injection and no control flow, isolating plain Typst compilation cost — a like-for-like counterpart to the staticminimal.tex.- A small harness runs a warmup phase (to absorb first-run / JIT / package-fetch costs) followed by N timed iterations, then aggregates min / median / mean / max and the output PDF size. It also reports each engine's on-disk binary size.
- Document: a representative minimal one page resume. The same content drives the
Typst template via injected JSON; the LaTeX template is a comparable
self-contained
.tex;raw.typis the fully-expanded Typst equivalent with no injection, used by thetypst-rawengine . - Metrics:
- Compile time: min / median / mean / max over N timed iterations (after warmup).
- Engine binary size (proxy for container/footprint cost).
- Output PDF size.
- Peak memory: peak resident set size (RSS) of the engine
process during compile, sampled from
/proc/<pid>/status(VmHWM). Linux-only; on other platforms it reports 0. This captures the engine's own working set, not the Go runner's. - Default accessibility (Typst emits tagged/UA-friendly PDFs by default; LaTeX requires configuration).
- Reproducibility: engine versions are pinned in the Docker image. Host
runs use whatever is on
PATH. Warmup isolates cold-start/package-fetch effects from steady-state timing.
Results for a minimal one-page resume (30 iterations after 3 warmup runs).
| Engine | BinSize | Mean | Median | Min | Mem(med) | Mem(max) | PDFSize |
|---|---|---|---|---|---|---|---|
typst |
53.2 MB | 20.333ms | 20.389ms | 19.36ms | 29.9 MB | 29.9 MB | 40.8 KB |
typst-raw |
53.2 MB | 19.823ms | 19.672ms | 18.443ms | 29.5 MB | 29.6 MB | 39.6 KB |
latex |
53.2 MB | 929.492ms | 823.36ms | 782.631ms | 217.0 MB | 219.6 MB | 27.4 KB |
Key observations:
- Compile speed: Both Typst engines compile in ~20ms, while LaTeX (Tectonic)
takes ~820ms median — a 40× difference. The multi-pass
.auxcycle and TeX Live package operations are the main contributors. - Memory footprint: Typst peaks at ~30 MB RSS, compared to LaTeX's ~220 MB. The huge difference is Tectonic's bundled TeX Live engine state (font/metric caches, package loading, hyphenation tables).
- Injection overhead: Comparing
typst(JSON injection + loops) vstypst-raw(static) shows negligible overhead — roughly 0.5ms and ~0.4 MB in memory. Typst's dynamic features are effectively free for resume-sized documents, meaning you get the full power of data-driven templates without a meaningful performance penalty. - PDF size: Typst produces slightly larger PDFs (40 KB vs 27 KB) due to tagged/accessible PDF metadata by default. LaTeX requires explicit configuration for comparable accessibility, so the smaller file reflects a less standards-compliant output.
Dockerfilebuilds the Go binaries and installs pinned engine versions:- Typst
v0.15.0(x86_64-unknown-linux-musl) - Tectonic
0.16.9(x86_64-unknown-linux-gnu) - DejaVu fonts (used by the Typst template) +
fontconfig.
- Typst
docker-compose.ymldefines abenchservice that mounts the repo and runs the benchmark.- The Go runner orchestrates the stack: with
--docker, it shellsdocker compose run --rm(settingTYPST_BENCH_IN_DOCKER=1to avoid recursion), runs the benchmarks inside the container, streams the report to the host, then tears the container down.
# Simplest approach using docker-compose
docker compose up
# Local (requires `typst` and `tectonic` on PATH)
go run ./cmd/bench --engine all --iterations 30
# Writes ./out/typst.pdf and ./out/latex.pdf for inspection
go run ./cmd/bench --engine all --out out
go run ./cmd/bench --engine typst --json # JSON output
go run ./cmd/bench --engine typst-raw # static Typst doc, no injection
go run ./cmd/demo # writes out/resume.pdf
# Via Docker (engines pinned, fully reproducible)
# Final PDFs land in ./out (the container's /app/out is mounted to .)
go run ./cmd/bench --docker
go run ./cmd/bench --docker --out out
make bench # local
make docker-bench # docker
# Idiomatic Go benchmarks
go test -bench=. -benchtime=10x ./...The benchmark writes a final PDF per engine. By default it copies them to
./out/<engine>.pdf (--out flag; set it to empty to skip). Use these
to eyeball that both engines render the same resume content.
- Tectonic fetches TeX Live packages on first compile (cached afterward); the warmup phase absorbs that cost. An offline / no-network container will fail the LaTeX run until packages are pre-cached.
- Engine versions in the
Dockerfileare pinned for reproducibility and should be bumped deliberately (and re-recorded inresults/) when upgraded.
MIT — see LICENSE.