Last month I spent a Saturday rewriting a Rust-to-WASM image metadata parser in TypeScript. The WASM version had been in production for eight months. It worked fine. Nobody complained about it. But I'd been staring at our performance traces and something bugged me — the WASM parser was spending more time moving data across the JS-WASM boundary than it spent actually parsing. So I rewrote it. Pure TypeScript, no WASM. The result was 38% faster for our median payload. I didn't write better algorithms. I just stopped paying the border crossing tax.
That experience broke something in my brain. I'd been one of those developers who assumed WebAssembly was the fast option, full stop. Turns out, that assumption is wrong more often than most of us realize.
The "WASM Is Always Faster" Myth Needs to Die
Here's the mental model most developers carry: compiled language goes to WASM, WASM runs at near-native speed, therefore WASM is faster than JavaScript. Each step in that chain is roughly true. But the conclusion doesn't follow, because it ignores the cost of everything around the computation — getting data in, getting results out, and the overhead of running two runtimes side by side.
JavaScript engines are insanely good. V8, SpiderMonkey, and JavaScriptCore represent decades of optimization work funded by some of the richest companies on the planet. They do speculative JIT compilation, inline caching, escape analysis, and hidden class transitions. For many workloads — especially the string-heavy, object-heavy, callback-heavy patterns common in web apps — a modern JS engine generates machine code that's surprisingly close to what you'd get from a static compiler.
WASM doesn't get those optimizations. It's AOT-compiled. What you ship is what runs. That's a feature for predictability, but it means WASM can't adapt to runtime patterns the way a JIT can.
The Boundary Crossing Problem: Death by a Thousand Calls
This is the big one. Every call from JavaScript into WASM (or back) has overhead. The engine has to marshal data, validate types, switch execution contexts. A single crossing is cheap — a few microseconds. But workloads that cross the boundary thousands of times per operation get murdered by this overhead.
I see this pattern constantly: a team writes a parser, transformer, or validator in Rust, compiles to WASM, and then wraps it in JavaScript glue code that calls into WASM for every node, every token, every step. The WASM core might be blazing fast in isolation, but the glue code turns it into a toll road.
// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source); // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) { // JS land
wasmParser.transform(node, newValue); // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.
I benchmarked this exact scenario on a real codebase. The WASM version processed a batch of 100 config files in 340ms. The TypeScript port did it in 195ms. Not because TypeScript is a faster language — it's not — but because the work never left a single execution context.
Why Strings Destroy WASM Performance
WASM operates on linear memory — a flat buffer of bytes. JavaScript strings are a completely different beast. They're engine-managed objects stored in specialized internal formats (Latin1, UTF-16, ropes, cons strings). Moving a string from JS into WASM means encoding it to UTF-8, allocating space in linear memory, and copying the bytes over. Getting a string back out means copying again and decoding.
For number-crunching code, this doesn't matter. You pass a TypedArray in, get numbers back out. But for anything that touches strings heavily — parsers, template engines, validators, serializers — you're paying the encode-copy-process-copy-decode tax on every string fragment.
// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.
I profiled our WASM parser and found that 31% of total execution time was string serialization. Not parsing. Not tree building. Just moving strings back and forth across the boundary. The TypeScript version eliminated that entire category of work.
If your hot path is string-heavy, WASM is probably making it slower, not faster. The serialization overhead is real and it compounds fast.
JIT Compilation: The Performance Edge Nobody Talks About
WASM's AOT model means you get predictable, consistent performance. That's great for some use cases. But JavaScript's JIT model means V8 watches your code run and then generates optimized machine code tuned to the actual data flowing through it. Over time, the JIT-compiled code can be ridiculously fast.
Take object shape specialization. If you process an array of objects that all have the same properties in the same order, V8 detects that monomorphic pattern and generates machine code that accesses properties by fixed memory offset — no hash table lookup, no type checking. It's basically C-struct performance from dynamic JavaScript.
// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number; // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.
WASM can't do this. Its performance characteristics are fixed at compile time. For tight, predictable numerical loops, that's fine — the Rust compiler already optimized the heck out of it. But for the messy, polymorphic, callback-heavy code that characterizes most web applications, the JIT's ability to specialize at runtime is a genuine advantage.
Bundle Size and Cold Start: The Metrics You're Forgetting
Throughput isn't the only performance metric. Users experience load time, interaction latency, and time to first result. WASM has real costs in all three.
- A Rust WASM module with wasm-bindgen typically ships 100KB-1.5MB gzipped. The equivalent TypeScript, minified and gzipped, is usually 10-40KB.
- WASM modules must be compiled to native code before they execute. Streaming compilation helps, but it's still work the browser has to do.
- JavaScript engines parse lazily — functions aren't compiled until they're called. WASM pays the full compilation cost upfront.
- V8's code cache means repeat visits skip JS parsing entirely. WASM compilation caching exists but is less mature.
Here's a real comparison from our project. The WASM parser bundle was 380KB gzipped. The TypeScript replacement was 26KB. On a good connection, whatever. On a throttled 3G connection (which I simulate for every performance review), the WASM version added 1.8 seconds of additional load time. That's not a rounding error — that's the difference between a user staying and bouncing.
Cold start mattered too. The WASM module took ~110ms to compile and instantiate on a mid-range phone. The TypeScript version was interactive in under 20ms. For a tool that users reach for dozens of times a day, that adds up to real frustration.
When WASM Actually Wins (And You Should Use It)
I don't want to give the impression that WASM is a bad technology. It's a great technology with a specific sweet spot. The problem is that people use it outside that sweet spot and then wonder why things got slower.
WASM wins when the computation is heavy, the boundary crossings are few, and the data is numeric. Think: image filters operating on pixel buffers, cryptographic hashing, physics simulations, audio DSP, video codecs. You shove a big chunk of data into linear memory, let WASM crunch it in a tight loop, and pull the result back out. One crossing in, one crossing out, tons of computation in between. That's where WASM shines.
WASM also wins when you need deterministic performance. JIT compilation is powerful but unpredictable — the first few calls to a JS function run interpreted, then baseline-compiled, then maybe optimizing-compiled. If you need consistent latency from the very first invocation (game loops, real-time audio, financial calculations), WASM's ahead-of-time compilation gives you that.
And WASM is obviously the right call when you're porting an existing native codebase. Nobody should rewrite SQLite in JavaScript to avoid WASM overhead. That would be insane. The existing C code represents decades of optimization that you'd never replicate.
A Practical Decision Framework: WASM or TypeScript?
After going through this exercise across three projects, I've landed on a simple checklist. It's not perfect, but it's saved me from making the wrong call twice now.
- Count boundary crossings. If your WASM module calls into JS (or vice versa) more than a few hundred times per operation, TypeScript will probably win. Profile it to be sure.
- Look at your data types. Mostly numbers and typed arrays? WASM is a good fit. Mostly strings, objects, and callbacks? Stay in JS.
- Measure startup cost. If the code runs on page load or in response to user clicks, WASM's compilation overhead matters. If it runs in a long-lived worker, startup amortizes away.
- Check your bundle budget. If you're already struggling with bundle size, adding a 400KB WASM module is going to hurt.
- Consider DX cost. WASM adds a Rust or C++ build system, source map complexity, and harder debugging. If the performance gain is marginal, the complexity isn't worth it.
- Benchmark with real data. Microbenchmarks lie. The overhead patterns only show up with production-sized inputs and realistic call patterns.
What I Learned From the Rewrite: Before and After
Let me lay out the actual numbers from our parser migration. These aren't synthetic benchmarks — they're from processing our real document corpus of 200 files ranging from 50 to 2,000 lines.
Metric | Rust/WASM | TypeScript | Delta
------------------------|--------------|--------------|--------
Median parse time | 23.1ms | 14.4ms | -38%
P99 parse time | 58.3ms | 30.7ms | -47%
Bundle size (gzip) | 380KB | 26KB | -93%
Cold start | 142ms | 18ms | -87%
Boundary crossings/doc | ~12,000 | 0 | -100%
String serialization | 31% of time | 0% | eliminated
Debug/profile ease | painful | native | huge win
The TypeScript rewrite wasn't a direct port. I redesigned the AST representation to play nice with V8's optimizer — monomorphic object shapes, numeric enums instead of string tags, flat array storage instead of pointer-heavy trees. These are optimizations you'd never think about in Rust because the Rust compiler handles that level of detail. In JavaScript, you have to meet the JIT halfway.
// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number; // numeric, not string — cheaper comparisons
start: number; // byte offset
end: number;
parent: number; // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number; // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}
The key insight? The fastest code isn't the code written in the fastest language. It's the code that works with its runtime instead of against it. Our Rust code was excellent Rust. But once compiled to WASM and embedded in a JavaScript application, it was fighting the platform at every turn.
Stop Assuming, Start Measuring
I still use WASM. I've got a WASM-based image processing pipeline that's 4x faster than anything I could write in JavaScript, because it operates on raw pixel buffers with zero boundary crossings. Right tool, right job.
But I've stopped defaulting to WASM when I need performance. My new default is to write the TypeScript version first, measure it with real data, and only reach for WASM when the profiler tells me I need it — and only for the specific hot path that's actually slow. Not the whole module. Not the whole feature. Just the tight computational loop that genuinely benefits from ahead-of-time compilation.
Write it in TypeScript. Measure it. If it's fast enough, ship it. If it's not, move the hot loop — and only the hot loop — to WASM. You'll end up with less code, smaller bundles, and faster applications.
The web platform gives you two powerful execution models. Use both. But use them where they actually help, not where your intuition says they should.