tsrs vs tsc on a real 6,600-file Next.js app: 11x faster, zero missed errors, and why we are not switching yet
A week after Cloudflare and VoidZero announced tsrs, a Rust port of the TypeScript 7 compiler, I benchmarked it against the compiler we already run. The vendor numbers say up to 8x faster and about 70% less memory than TypeScript 7.
I wanted to know three things for our own codebase: Is it faster on our code, not on someone else's benchmark? Does it catch every error the official compiler catches? Does the speedup land where we actually wait?
Short version: on my laptop it was 11x faster on a cold check and used about half the memory, and it missed zero errors across 57 test cases. We are still not switching yet. The reasons are below, along with the gotchas that nearly made me publish wrong numbers.
The setup
Codebase: a Next.js 16 app deployed to Cloudflare Workers. The type check covers about 6,600 tracked TypeScript files in one project, with no project references.
Current compiler: TypeScript 7.0.2, which is already the native Go compiler. So this is not "Rust vs the old JavaScript tsc". It is Rust vs Go, and the old 10-minute type checks are already gone.
Candidate: tsrs@0.20.0-ts7.1.0-beta, default mode. Its faster preset (--fast) is only in source builds right now, so I left it out.
Third subject: typescript@7.1.0-beta. tsrs ports the 7.1 line, not 7.0. Without 7.1 in the matrix you cannot tell "Rust is faster" apart from "TypeScript 7.1 is faster". That turned out to matter.
Machine: an 8 GB, 6-core Apple laptop (A18 Pro), Node 22. All runs used --noEmit and --checkers 2.
Tools: hyperfine for timing (cold = no tsbuildinfo, warm = cache kept), plus peak resident memory per run.
Before running anything I wrote down the pass bar: zero missed errors, plus either 2x faster or 40% less memory. If you set the bar after seeing the numbers, you will always find a reason to switch.
Step zero: where does the time actually go?
Before testing the new tool, I pulled 14 days of CI history: 483 runs of our "fast signal" job (lint + type check on every PR push) and 192 runs of the post-merge guard job.
Fast signal job / Post-merge guards:
- Whole job, median: 96 s / 130 s
- Type check, median: 27 s / 28 s
- Type check share: 28% / 21%
- Most an 8x faster checker could save: 24 s per run / 24 s per run
Two surprises. First, the "about 95 seconds" type-check time written in a comment in our repo was a stale laptop number. On the CI runner it is 27 seconds.
Second, even a perfect 8x win only shaves about 24 seconds off a job that people mostly wait on for other reasons (install, checkout, queue). At our volume that is about 20 runner-minutes a day. So the CI win has a hard ceiling. The laptop is a different story.
Results: speed and memory
Laptop, 3 runs each, medians:
- tsc 7.0.2 (current) / tsc 7.1-beta / tsrs 0.20
- Cold wall time: 46.9 s / 30.0 s / 4.2 s
- Cold CPU time: 69.6 s / 52.9 s / 8.5 s
- Cold peak memory: 1,766 MiB / 1,713 MiB / 907 MiB
- Warm wall time: 2.62 s / 2.06 s / 0.56 s
- Warm CPU time: 8.9 s / 7.9 s / 2.4 s
Compared with what we run today, that is 11x faster on a cold check, 8x less CPU, about half the memory, and 4.7x faster with a warm cache.
Part of the gain is TypeScript 7.1 itself: 7.1-beta alone was about 1.6x faster than 7.0.2 on a cold check. tsrs is still about 7x faster than 7.1-beta.
On an 8 GB machine, the memory number matters as much as the speed. A cold tsc check took about 1.8 GB, enough to push a busy laptop into swap.
Results: does it catch the same errors?
A faster type checker that misses one real error just ships a bug faster, so this part mattered more than the timing.
Seeded mistakes. I appended 50 type-breaking statements to 50 different source files, sampled with a fixed random seed. They cover:
- wrong assignment types
- possibly-undefined access
- missing and extra object properties
- wrong argument counts
- unknown names
- generic constraint violations
- imports of exports that do not exist
- missing return paths
- writes to readonly arrays
- methods called on a Promise
- bad JSX props
One run per compiler is enough, because each mistake lives in its own file and line.
Real past failures. From the CI logs I took every commit where the type check really failed in those 14 days: 7 commits, 13 errors. I checked out each one and ran all three compilers on it.
- tsc 7.0.2 / tsc 7.1-beta / tsrs
- Seeded mistakes flagged: 50/50 / 50/50 / 50/50
- Past CI failures caught: 7/7 / 7/7 / 7/7
- Errors tsc found that tsrs did not: n/a / n/a / 0
The first diff did show tsrs reporting one diagnostic fewer than tsc. It looked like a miss until I read the raw output. tsc printed the same error twice for one file, once with a normal relative path and once with a strange path through macOS's /var to /private/var symlink. tsrs printed it once. Same file, line, column and error code. If I had stopped at the counts, I would have published a false bug.
Gotchas worth knowing before you try it
- Benchmark against the right TypeScript version. tsrs 0.20 tracks TypeScript 7.1-beta. If you are on 7.0, some of the speedup you see is 7.1's. Put both in the comparison.
- The exit code changes. A failed check exits with 1 on tsc 7.0 and with 2 on tsc 7.1 and tsrs. A CI script that tests exit == 1 instead of "non-zero" will break.
- Keep a separate tsbuildinfo per compiler. If two compilers share an incremental cache, your "warm" numbers measure one compiler reading the other's cache. Pass
--tsBuildInfoFileper subject. - Generated files make old commits look worse. Replaying old commits in a fresh checkout showed 9 extra errors per commit, from a gitignored generated file that a build step normally writes. All three compilers agreed, so parity still held. But check the baseline compiler reproduces CI's errors before you trust a comparison.
- hyperfine 2.0 changed its JSON format.
results[].timesbecameresults[].measurements[]. The upside is that 2.0 records CPU time and peak memory per run, which saved me a separate memory pass. - Cold tsc runs are noisy on a shared laptop. tsc 7.0's cold runs ranged from 30 to 78 seconds; tsrs ranged from 4.1 to 4.7 seconds. Report medians and the spread, not a single run.
- Some flags are missing. tsrs has no
--generateTrace(it warns and continues), no--showConfigand no--init. If your profiling workflow depends on traces, keep tsc around for that.
Caveats on these numbers
- 3 timed runs per configuration on one laptop with other work running. The gap is large enough to survive that noise, but treat the exact ratios as rough.
- Only
--checkers 2was tested. CI uses the default of 4. I have not measured it on our CI runner yet. - The CI saving is a ceiling from history, not a measurement.
- The seeded mistakes are simple appended statements, and the real-failure set is small (13 errors). The tsrs project reports much broader conformance (all 13,700 error baselines in the TypeScript test suite match), but that is their evidence, not ours.
Why we are holding off
On our code, the numbers clear the bar we set in advance. We are still waiting, for reasons that have nothing to do with speed:
It is a 0.x beta about a month old, with eight npm releases in the 17 days before this test.
Version skew. Our editors run TypeScript 7.0.2, tsrs checks with 7.1-beta semantics. They agree on our code today. They may not on some future edge case, and then the editor and CI would disagree.
Open memory-safety issues upstream. One mitigating detail: a panic exits with a non-zero code, so CI fails closed, not falsely green. Output is buffered, though, so a crash can lose the error text.
Outside pull requests are closed. You can report bugs, not fix them.
Our plan once it matures:
- Pin the exact version.
- Swap it in for the CLI type check only. Editors and the framework build keep using
typescript. - Keep a one-line environment switch back to tsc.
- For the first two weeks, run both compilers in CI and fail on any disagreement.
The laptop is where we wait the most, and that is where it pays off most: 47 seconds down to 4, using half the memory.
Reproduce it
The approach needs nothing exotic:
- Install each compiler into its own throwaway directory, so your lockfile is untouched.
- Run
hyperfine --prepareto delete each compiler's tsbuildinfo for cold runs. - Normalize
--pretty falseoutput to file:line:col:code and diff the sets. - Seed one mistake per file and score by line.
- Replay your own CI's failed commits.
Steps 4 and 5 test what actually matters. Your CI history already holds the best test set for a new type checker: the errors your team really makes.
Comments
No comments yet. Start the discussion.