Back to Home

Parser acceleration by 3 times: transition from Rust/WASM to TypeScript

The article describes the case of rewriting the DSL parser from Rust/WASM to TypeScript, which led to 2.6–3.3x acceleration in processing. It analyzes the reasons for overhead at the WASM-JS boundary and the advantages of the incremental algorithm with instruction caching.

How we sped up the parser 3 times by abandoning WASM
Advertisement 728x90

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 = expression instructions.
  • 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 OutputNode format ready for React rendering.

Each stage is critical for overall performance since the parser runs on every chunk of the LLM output stream.

Google AdInline article slot

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.

Google AdInline article slot

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.

Google AdInline article slot

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-bindgen can 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

Advertisement 728x90

Read Next