NOW LET US – AI RAG SaaS Studio TP.HCM
NOW LET US
Digital Product Studio
Back to news
DEV-TOOLS...3 min read

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

Share
NOW LET US Article – 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.
© 2026 Now Let Us. All rights reserved.

Source: Hacker News

Advertisement
Ad slot ready: 5887729102

More in this category

EXPLORE TOPICS

Discover All Categories

Deep dive into the specific technology sectors that matter most to you.