Ditching WASM: How Rewriting a Rust Parser in TypeScript Boosted Speed 3x
Switching from Rust/WASM to TypeScript for parsing a custom DSL in the browser eliminated runtime boundary overhead and enabled an incremental algorithm, slashing total stream processing time by 2.6–3.3x.
openui-lang Parser Architecture
The parser converts a custom DSL generated by a language model (LLM) into a React component tree. The pipeline has six sequential stages, each handling a specific task:
- Autocloser — ensures syntactic correctness of partial text by adding the minimal number of closing brackets and quotes.
- Lexer — performs a single-pass scan of the input string to generate typed tokens.
- Splitter — breaks the token stream into individual
id = expressioninstructions. - Parser — uses recursive descent to build an abstract syntax tree (AST) from expressions.
- Resolver — resolves variable references, supports hoisting declarations, and detects cyclic dependencies.
- Mapper — transforms the internal AST into the public
OutputNodeformat ready for React rendering.
Each stage is critical for overall performance since the parser runs on every chunk of the LLM output stream.
The Problem: WASM–JS Boundary Overhead Dominates
The original Rust implementation compiled to WebAssembly wasn't slow in parsing itself—it was crippled by data transfer costs across runtimes. Every call involved:
- Copying the string from JS heap to WASM linear memory.
- Serializing the parse result to JSON inside Rust.
- Copying the JSON string back to the JS heap.
- Deserializing the string into a JS object via
JSON.parse().
Trying serde-wasm-bindgen for direct object transfer without JSON serialization actually worsened performance by 9–29%. This stemmed from recursively materializing Rust data structures into native JS objects, triggering tons of fine-grained runtime crossings.
The Solution: Full TypeScript Port + Incremental Algorithm
Ditching WASM entirely for TypeScript killed the data transfer overhead. But the real game-changer was optimizing for streaming with an incremental approach.
The naive version re-parsed the entire accumulated string from scratch on every new chunk, leading to quadratic O(N²) complexity relative to chunk count.
We introduced an incremental strategy with instruction-level caching:
- Instructions ending with a newline at zero nesting level are deemed immutable.
- Their ASTs are cached after the first parse.
- On new chunks, only the tail (unfinished) instruction is re-parsed.
- The final result merges cached ASTs with the fresh tail parse.
This dropped complexity to linear O(L), where L is total document length.
Benchmarks and Performance Results
We tested on three realistic LLM-generated fixtures.
Single Parser Call (median time, microseconds):
| Fixture | TypeScript | WASM (via JSON) | Speedup |
|--------------|------------|-----------------|---------|
| simple-table | 9.3 | 20.5 | 2.2x |
| contact-form | 13.4 | 61.4 | 4.6x |
| dashboard | 19.4 | 57.9 | 3.0x |
Full Stream Processing Cost (median time, microseconds):
| Fixture | Naive TS (O(N²)) | Incremental TS (O(N)) | Speedup |
|--------------|-------------------|-----------------------|---------|
| simple-table | 69 | 77 | — |
| contact-form | 316 | 122 | 2.6x |
| dashboard | 840 | 255 | 3.3x |
No speedup for simple-table since it's a single instruction with nothing to cache. Gains scale with instruction count.
Key Takeaways
- In Rust/WASM setups with frequent calls, the real killer isn't compute—it's data copying and serialization across boundaries.
- Direct WASM-to-JS object passing via
serde-wasm-bindgencan be slower than tuned JSON due to endless internal conversions. - Algorithmic wins (O(N²) to O(N)) often beat language or runtime micro-optimizations.
- WASM shines for low-interaction tasks (image processing), but flops for frequent parsing into complex JS objects.
When to Use WASM (and When Not)
✅ WASM Wins:
- Compute-heavy tasks with bulk input and scalar/in-place output (media processing, crypto, physics sims).
- Porting existing C/C++ libs to browser without a full rewrite.
❌ WASM Loses:
- Frequent parsing of structured text needing complex JS object returns.
- Hot paths with tiny inputs where boundary overhead doesn't amortize.
— Editorial Team
No comments yet.