Optimizing Browser Performance: Lessons from a WASM Parser

Optimizing Browser Performance: Lessons from a WASM Parser

Optimizing Browser Performance: Lessons from a WASM Parser

As developers, we’re always looking for ways to improve the performance of our applications. In this article, we’ll explore the lessons learned from optimizing a WASM parser for browser use.

The Problem with WASM

When we built our openui-lang parser in Rust and compiled it to WASM, we thought we had a winning combination. Rust is fast, WASM gives you near-native speed in the browser, and our parser is a reasonably complex multi-stage pipeline. But, as we soon discovered, the logic was sound, but the implementation was flawed.

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: 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. This overhead is due to the boundary crossing between the JS world and the WASM world.

JS world

wasmParse(input)

WASM world

copy string: JS heap → WASM linear memory (allocation + memcpy)

copy JSON string: WASM → JS heap (allocation + memcpy)

JSON.parse(jsonString) ← deserialize result

return ParseResult

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.

However, this approach was 30% slower. The reason is that JS cannot read a Rust struct’s bytes from WASM linear memory as a native JS object. 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.

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.

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):

  • Scan buf from completedEnd for depth-0 newlines
  • For each complete statement found: parse + cache AST → advance completedEnd
  • Pending (last, incomplete) statement: autoclose + parse fresh
  • Merge cached + pending → resolve + map → return ParseResult

Results

We benchmarked the performance of the parser using two different approaches: one-shot parse and full-stream total parse cost.

One-Shot Parse

What is measured: A single parse(completeString) call on the finished output string.

How it was run: 30 warm-up iterations to stabilise JIT, then 1000 timed iterations using performance.now() (µs precision). The median is reported.

Results: One-Shot Parse (median µs, 1000 runs)

  • Fixture: simple-table → 9.32µs (TypeScript) vs 20.52µs (WASM)
  • Fixture: contact-form → 13.46µs (TypeScript) vs 61.47µs (WASM)
  • Fixture: dashboard → 19.45µs (TypeScript) vs 57.97µs (WASM)

Full-Stream Total Parse Cost

What is measured: The total parse overhead accumulated across every chunk call for one complete document.

How it was run: Documents are replayed in 20-char chunks. Each chunk triggers a parse() (naïve) or push() (incremental) call. Total time across all calls is recorded. 100 full-stream replays, median taken.

Results: Full-Stream Total Parse Cost (median µs across all chunks)

  • Fixture: simple-table → 6977µs (naïve TS) vs none (incremental TS)
  • Fixture: contact-form → 316122µs (naïve TS) vs 1222µs (incremental TS)
  • Fixture: dashboard → 840255µs (naïve TS) vs 2553µs (incremental TS)

Conclusion

In conclusion, we’ve learned that WASM can be a performance bottleneck in certain scenarios. By eliminating the boundary entirely and using statement-level incremental caching, we were able to achieve a 2.2-4.6x faster per-call cost and a 2.6-3.3x lower total streaming cost.

When WASM actually helps is when it’s used for compute-bound tasks with minimal interop, such as image/video processing, cryptography, physics simulations, or audio codecs. Large input → scalar output or in-place mutation. The boundary is crossed rarely.

However, for parsing structured text, WASM is not the best choice. We should use TypeScript and eliminate the boundary entirely.