https://www.openui.com/blog/rust-wasm-parser OpenUI OpenUI LangPlaygroundAPI ReferenceBlog On this page The PipelineThe WASM Boundary TaxAttempted Fix: Skip the JSON Round-TripBenchmark: JSON string vs direct JsValue (1000 runs, us per call)The Real Fix: Eliminate the Boundary EntirelyBenchmark Method: One-Shot ParseResults: One-Shot Parse (median us, 1000 runs)The Algorithmic Problem: O(N2) StreamingThe Fix: Statement-Level Incremental CachingBenchmark Method: Full-Stream Total Parse Cost Results: Full-Stream Total Parse Cost (median us across all chunks) Why the two TS numbers look differentSummaryWhen WASM Actually Helps Key Takeaways Rewriting our Rust WASM Parser in TypeScript We rewrote our Rust WASM Parser in TypeScript - and it got 3x Faster Thesys Engineering Team*Fri Mar 13 2026 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 Pipeline 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 - lexer - splitter - parser - resolver - mapper - ParseResult * 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 The WASM Boundary Tax Every call to the WASM parser pays a mandatory overhead regardless of how fast the Rust code itself runs: JS world WASM world -------------------------------------------------------- wasmParse(input) | +- copy string: JS heap - WASM linear memory (allocation + memcpy) | | Rust parses fast | serde_json::to_string() - serialize result | +- copy JSON string: WASM - JS heap (allocation + memcpy) | JSON.parse(jsonString) - deserialize result | return ParseResult 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. Attempted Fix: Skip the JSON Round-Trip 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. Benchmark: JSON string vs direct JsValue (1000 runs, us per call) 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. The Real Fix: Eliminate the Boundary Entirely We 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. Benchmark Method: One-Shot Parse What is measured: A single parse(completeString) call on the finished output string. This isolates per-call parser cost. How it was run: 30 warm-up iterations to stabilise JIT, then 1000 timed iterations using performance.now() (us precision). The median is reported. Fixtures are real LLM-generated component trees serialised in each format's real streaming syntax. Fixtures: * simple-table -- root + one Table with 3 columns and 5 rows (~180 chars) * contact-form -- root + form layout with 6 input fields + submit button (~400 chars) * dashboard -- root + sidebar nav + 3 metric cards + chart + data table (~950 chars) Results: One-Shot Parse (median us, 1000 runs) 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 The Algorithmic Problem: O(N2) Streaming Eliminating WASM fixed the per-call cost, but the streaming architecture still had a deeper inefficiency. The parser is called on every LLM chunk. The naive approach accumulates chunks and re-parses the entire string from scratch each time: Chunk 1: parse("root = Root([t") - 14 chars Chunk 2: parse("root = Root([tbl])\ntbl = T") - 27 chars Chunk 3: parse(full_accumulated_string) - ... For a 1000-char output delivered in 20-char chunks: 50 parse calls processing a cumulative total of ~25,000 characters. O(N2) in the number of chunks. The Fix: Statement-Level Incremental Caching Statements terminated by a depth-0 newline are immutable -- the LLM will never come back and modify them. We added a streaming parser that caches completed statement ASTs: State: { buf, completedEnd, completedSyms, firstId } On each push(chunk): 1. Scan buf from completedEnd for depth-0 newlines 2. For each complete statement found: parse + cache AST - advance completedEnd 3. Pending (last, incomplete) statement: autoclose + parse fresh 4. Merge cached + pending - resolve + map - return ParseResult Completed statements are never re-parsed. Only the trailing in-progress statement is re-parsed per chunk. O(total_length) instead of O(N2). Benchmark Method: Full-Stream Total Parse Cost What is measured: The total parse overhead accumulated across every chunk call for one complete document. This is different from the one-shot benchmark -- it measures the sum of all parse calls during a real stream, not a single call. This is the number that affects actual user-perceived responsiveness. How it was run: Documents are replayed in 20-char chunks. Each chunk triggers a parse() (naive) or push() (incremental) call. Total time across all calls is recorded. 100 full-stream replays, median taken. Results: Full-Stream Total Parse Cost (median us across all chunks) Naive TS Incremental TS Fixture (re-parse every (cache completed) Speedup chunk) none (single simple-table 69 77 statement, no cache benefit) contact-form 316 122 2.6x dashboard 840 255 3.3x The simple-table fixture is a single statement -- there's nothing to cache, so both approaches are equivalent. The benefit scales with the number of statements because more of the document gets cached and skipped on each chunk. Why the two TS numbers look different The one-shot table shows 13.4us for contact-form; the streaming table shows 316us (naive). These are not contradictory -- they measure different things: * 13.4us = cost of one parse() call on the complete 400-char string * 316us = total cost of ~20 parse() calls during the stream (chunk 1 parses 20 chars, chunk 2 parses 40 chars, ..., chunk 20 parses 400 chars -- cumulative sum of all those growing calls) Summary Approach Per-call Full-stream Notes cost total WASM + JSON 20-61us baseline Copy overhead each call round-trip WASM + 22-79us +9-29% Hundreds of internal serde-wasm-bindgen slower boundary crossings TypeScript (naive 9-19us 69-840us No boundary, but O(N2) re-parse) streaming TypeScript 9-19us 69-255us No boundary + O(N) (incremental) streaming End result: 2.2-4.6x faster per call and 2.6-3.3x lower total streaming cost. When WASM Actually Helps This experience sharpened our thinking on the right use cases for WASM: Compute-bound with minimal interop: image/video processing, cryptography, physics simulations, audio codecs. Large input - scalar output or in-place mutation. The boundary is crossed rarely. Portable native libraries: shipping C/C++ libraries (SQLite, OpenCV, libpng) to the browser without a full JS rewrite. Parsing structured text into JS objects: you pay the serialization cost either way. The parsing computation is fast enough that V8's JIT eliminates any Rust advantage. The boundary overhead dominates. Frequently-called functions on small inputs: if the function is called 50 times per stream and the computation takes 5us, you cannot amortise the boundary cost. Key Takeaways 1. Profile where time is actually spent before choosing the implementation language. For us, the cost was never in the computation - it was always in data transfer across the WASM-JS boundary. 2. "Direct object passing" through serde-wasm-bindgen is not cheaper. Constructing a JS object field-by-field from Rust involves more boundary crossings than a single JSON string transfer, not fewer. The boundary crossings happen inside the single FFI call, invisibly. 3. Algorithmic complexity improvements dominate language-level optimisations. Going from O(N2) to O(N) in the streaming case had a larger practical impact than switching from WASM to TypeScript. 4. WASM and JS do not share a heap. WASM has a flat linear memory (WebAssembly.Memory) that JS can read as raw bytes, but those bytes are Rust's internal layout - pointers, enum discriminants, alignment padding - completely opaque to the JS runtime. Conversion is always required and always costs something.