Casual Developer

Vercel's TypeScript Compiler Beats WebAssembly

dested

Quick disclosure: scriptc is Vercel Labs' compiler, not mine — I just had a benchmark and a hunch about it. Claude Opus 5 re-ran every measurement from scratch and built the wasm comparisons, then drafted this; I'm publishing it in my own voice. Flagging that because pretending otherwise would be weird.

scriptc is Vercel Labs' TypeScript-to-native compiler. It takes TypeScript, emits LLVM IR, and links a real native binary — no Node, no V8, no JavaScript engine in the output. I'd seen it beat node on a toy benchmark and wanted to know if that meant anything.

Two questions. Is it actually faster than shipping the same TypeScript to a browser? And if you skip the compiler entirely and hand-write the hot function in WebAssembly — the thing everyone reaches for when JavaScript is too slow — does scriptc still have a reason to exist?

Method

Naive recursive fib(45). Nothing else.

function fib(n: number): number {
    return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(45));

That's ~2.3 billion function calls, no allocation, no I/O, no strings. Pure call overhead plus one add. A terrible benchmark for a language and a very good one for a codegen backend — which is the part under test.

Every number below is best-of-three on one machine (Windows 11, hybrid x86), re-run in one sitting. CLI runtimes are wall clock for the whole process; browser numbers are performance.now() around the call.

Benchmark results
Benchmark results

Finding 1: scriptc ties hand-written C

runtime wall clock vs best
native C -O3 (int32) 2.13s 1.00×
scriptc 3.00s 1.41×
native C -O3 (double) 3.21s 1.51×
bun fib.ts 4.42s 2.07×
node fib.ts 5.85s 2.75×

scriptc at 3.00s against clang at 3.21s on the same recursion. That isn't "close to native." It's native, inside run-to-run noise, slightly ahead.

But look at the two C rows. Identical source, identical flags, 1.5× apart — the only difference is int versus double.

That gap is the entire story of this post.

Finding 2: number is a tax you cannot dodge

TypeScript's number is an IEEE-754 double. scriptc honors that. Every fib in the emitted IR is a double, every subtract is an fsub, every add is an fadd. It has to be — fib(2**53) has to behave like JavaScript says it behaves.

So scriptc was never competing with the 2.13s number. It's competing with 3.21s. And it wins.

V8 cheats here, and it's completely correct to. fib(45) returns 1134903170, which fits in an int32, so V8 speculates integers all the way down and only deoptimizes if something overflows. That's the right call when you have runtime feedback. An ahead-of-time compiler never sees it.

Finding 3: Chrome is node

engine compute
Chrome (visible window) 5.78s
node 5.79s
bun 4.29s

Same V8, 0.2% apart. There's no browser penalty on straight-line compute — once you're inside the loop, the sandbox costs nothing.

Bun's JavaScriptCore being 26% ahead of both surprised me more than anything else here.

Finding 4: wasm loses the fair fight

This is the one I expected to go the other way.

I compiled fib to wasm two ways — clang --target=wasm32 -O3 -flto and Zig -OReleaseFast — in both f64 and i32, and ran them in Chrome.

module compute
wasm i32 (zig) 2.74s
wasm i32 (clang) 2.78s
wasm f64 4.55s
plain JS 5.78s

wasm f64 is 4.55s. scriptc is 3.00s.

Same semantics. Same double arithmetic. Same fib. The native binary is 1.5× faster than the best wasm that preserves what TypeScript actually means.

That 2.78s wasm headline only exists because I changed number to int. That's not a compiler win, it's a different program — and even then it trails native int32 by 30%. The wasm tax is real, and -O3 doesn't make it go away.

clang and Zig landed within 1.5% of each other, which makes sense: same LLVM wasm backend underneath. Toolchain is not the lever.

The gotcha: hidden tabs run on the slow cores

My first Chrome measurement came back at 13.3s for JS and 4.65s for wasm. Off by 2.3×.

The tab was document.visibilityState === "hidden". Windows demoted the renderer process, and on a hybrid CPU "demoted" means the efficiency cores. I'd have shipped a chart claiming Chrome is 4× slower than node.

Every browser number in this post is from a focused, visible window, cross-checked against headless Chrome with --disable-renderer-backgrounding. They agreed to 0.2%.

If you benchmark a browser from an automation harness, check document.hasFocus() before you believe a single number.

TL;DR

  • scriptc: 3.00s. clang -O3 on the same double recursion: 3.21s. Parity with hand-written C.
  • scriptc is 1.9× faster than Chrome, 1.5× faster than bun, 1.95× faster than node.
  • scriptc is 1.5× faster than the best wasm that keeps TypeScript's semantics.
  • wasm only "wins" (2.78s) by quietly switching number to int32 — and still trails native int32 by 30%.
  • Chrome and node are the same engine and measure identically. Bun is 26% faster than both.
  • Zig and clang emit equivalently fast wasm.
  • Hidden browser tabs get demoted to efficiency cores. Check hasFocus().

Caveats, and they're big: one benchmark, one machine, one shape of workload. fib is call overhead and nothing else — no GC, no strings, no objects, no allocation. That is precisely where an AOT compiler looks its best and a tracing JIT looks its worst. Do not extrapolate this to real programs.

What I take from it is not the 3.00s. It's that scriptc pays the full double tax TypeScript demands and still beats a wasm module paying the exact same tax. The usual escape hatch — "drop to wasm when JS is too slow" — was costing more than anyone was measuring.