We rewrote our Rust WASM Parser in TypeScript – and it got 3x Faster

Despite Rust and WebAssembly being the go-to for web performance, a development team discovered that the memory boundary between WASM and JavaScript can negate all speed advantages, leading to a 3x performance boost after switching back to TypeScript.
We built the openui-lang parser in Rust and compiled it to WASM. The logic was sound: Rust is fast, WASM gives you near-native speed in the browser, and our parser is a reasonably complex multi-stage pipeline. Why wouldn't you want that in Rust?
Turns out we were optimising the wrong thing.
The openui-lang parser converts a custom DSL emitted by an LLM into a React component tree. It runs on every streaming chunk — so latency matters a lot. The pipeline has six stages:
Autocloser: makes partial (mid-stream) text syntactically valid by appending minimal closing brackets/quotes
Lexer: single-pass character scanner, emits typed tokens
Splitter: cuts the token stream into id = expression statements
Parser: recursive-descent expression parser, builds an AST
Resolver: inline all variable references (hoisting support, circular ref detection)
Mapper: converts internal AST into the public OutputNode format consumed by the React renderer
Every call to the WASM parser pays a mandatory overhead regardless of how fast the Rust code itself runs. The Rust parsing itself was never the slow part. The overhead was entirely in the boundary: copy string in, serialize result to JSON string, copy JSON string out, then V8 deserializes it back into a JS object.
The natural question was: what if WASM returned a JS object directly, skipping the JSON serialization step? We integrated serde-wasm-bindgen which does exactly this — it converts the Rust struct into a JsValue and returns it directly. It was 30% slower.
Here's why. JS cannot read a Rust struct's bytes from WASM linear memory as a native JS object — the two runtimes use completely different memory layouts. To construct a JS object from Rust data, serde-wasm-bindgen must recursively materialise Rust data into real JS arrays and objects, which involves many fine-grained conversions across the runtime boundary per parse() invocation.
Compare that to the JSON approach: serde_json::to_string() runs in pure Rust with zero boundary crossings, produces one string, one memcpy copies it to the JS heap, then V8's native C++ JSON.parse processes it in a single optimised pass. Fewer, larger, and more optimised operations win over many small ones.
| Fixture | JSON round-trip | serde-wasm-bindgen | Change | |---|---|---|---| | simple-table | 20.5 | 22.5 | -9% slower | | contact-form | 61.4 | 79.4 | -29% slower | | dashboard | 57.9 | 74.0 | -28% slower |
We reverted this change immediately and ported the full parser pipeline to TypeScript. Same six-stage architecture, same ParseResult output shape — no WASM, no boundary, runs entirely in the V8 heap.
| Fixture | TypeScript | WASM | Speedup | |---|---|---|---| | simple-table | 9.3 | 20.5 | 2.2x | | contact-form | 13.4 | 61.4 | 4.6x | | dashboard | 19.4 | 57.9 | 3.0x |
Eliminating WASM fixed the per-call cost, but the streaming architecture still had a deeper inefficiency. The naïve approach accumulates chunks and re-parses the entire string from scratch each time: O(N²) in the number of chunks. We added a streaming parser that caches completed statement ASTs, moving to O(total_length) instead of O(N²).
| Fixture | Naïve TS (re-parse every chunk) | Incremental TS (cache completed) | Speedup | |---|---|---|---| | simple-table | 69 | 77 | none | | contact-form | 316 | 122 | 2.6x | | dashboard | 840 | 255 | 3.3x |
End result: 2.2-4.6x faster per call and 2.6-3.3x lower total streaming cost.
This experience sharpened our thinking on the right use cases for WASM:
✅ Compute-bound with minimal interop: image/video processing, cryptography, physics simulations. The boundary is crossed rarely. ❌ Parsing structured text into JS objects: you pay the serialization cost either way. The boundary overhead dominates.
- Profile where time is actually spent before choosing the implementation language.
- Algorithmic complexity improvements dominate language-level optimisations.
- WASM and JS do not share a heap. Conversion is always required and always costs something.
Source: Hacker News










