Skip to content
rust webassembly performance engineering architecture

Why We Built ChainSolve with Rust and WebAssembly

A technical deep dive into why we chose Rust compiled to WebAssembly for ChainSolve's computation engine, and the performance gains we achieved.

By godfrey-engineering
Abstract visualization of Rust code compiling to WebAssembly with performance graphs

When we set out to build ChainSolve, we had a non-negotiable requirement: every calculation must execute in the browser with near-native performance. No server round-trips. No cloud dependencies. No data leaving the user’s machine. This requirement led us to Rust and WebAssembly.

The Performance Requirement

ChainSolve lets users build computation graphs with potentially thousands of blocks, each performing a mathematical operation on its inputs and passing results to downstream blocks. When a user changes an input value, the entire downstream subgraph must recompute and update in real time — ideally within a single animation frame (16ms at 60fps).

A pure JavaScript implementation of the computation engine hit a wall at around 200 blocks before frame drops became noticeable. We needed at least a 10x improvement to support our target of 5,000+ blocks per graph.

Why Rust?

We evaluated several languages that compile to WebAssembly: C, C++, Go, and Rust. We chose Rust for three reasons.

Memory Safety Without Garbage Collection

Rust’s ownership system guarantees memory safety at compile time without a garbage collector. This is critical for WebAssembly because Wasm’s linear memory model means that a use-after-free or buffer overflow does not just crash your tab — it corrupts the entire memory space silently. Rust eliminates these bugs by construction.

Zero-Cost Abstractions

Rust’s abstractions (generics, iterators, pattern matching) compile down to the same machine code you would write by hand in C. This means we can write clean, expressive code without paying a performance tax.

Excellent WebAssembly Toolchain

The wasm-pack tool, the wasm-bindgen crate, and the broader Rust-to-Wasm ecosystem are mature and well-documented. We can expose Rust functions to JavaScript with minimal boilerplate and no manual FFI glue code.

Architecture Overview

The ChainSolve computation engine has three layers:

  1. Step [counter(steps)] Graph Representation

    The computation graph is stored as an adjacency list with topological ordering. When the graph structure changes (blocks added, removed, or rewired), we recompute the topological order using Kahn’s algorithm. This determines the evaluation sequence.

  2. Step [counter(steps)] Evaluation Engine

    The engine walks the topological order and evaluates each block. Blocks are implemented as Rust trait objects with a common evaluate(&self, inputs: &[Value]) -> Result<Value, EvalError> interface. This allows us to add new block types without modifying the engine.

  3. Step [counter(steps)] JavaScript Bridge

    The bridge layer uses wasm-bindgen to expose a minimal API to JavaScript: create_graph(), add_block(), connect(), set_input(), evaluate(), and get_results(). Data is serialized as JSON for complex types and passed as raw f64 arrays for numeric data to minimize serialization overhead.

Benchmarks

We benchmarked the Wasm engine against an equivalent pure JavaScript implementation on three workloads:

WorkloadJS (ms)Wasm (ms)Speedup
100 blocks, linear chain2.10.1811.7x
1,000 blocks, branching graph34.22.414.3x
5,000 blocks, complex DAG412.022.118.6x

The speedup increases with graph complexity because the Wasm engine benefits from better cache locality and the absence of JavaScript’s dynamic type checks.

Web Worker Integration

The computation engine runs inside a Web Worker to prevent blocking the main thread. The communication pattern is straightforward:

  1. The main thread sends graph mutations (block added, connection changed, input updated) as structured messages to the Worker.
  2. The Worker applies mutations to the Wasm engine and triggers re-evaluation.
  3. Results are sent back to the main thread via postMessage.
  4. For large result sets, we use SharedArrayBuffer with Atomics to avoid the serialization cost of postMessage.

This architecture means the UI thread is never blocked by computation. Even if a graph takes 500ms to evaluate, the user can continue dragging blocks, scrolling the canvas, and interacting with the interface without any jank.

Lessons Learned

Building a production Wasm application taught us several lessons:

  1. Wasm binary size matters. Our initial build was 2.4MB. After enabling LTO, wasm-opt, and stripping debug symbols, we reduced it to 340KB gzipped. Every kilobyte counts for initial load time.

  2. Avoid frequent small allocations. Wasm’s allocator is simpler than a native allocator. We use arena allocation for per-evaluation temporary data, which reduced allocation overhead by 60%.

  3. Profile in the browser, not on the command line. Performance characteristics differ between native Rust and Wasm. Chrome DevTools’ Performance panel with Wasm profiling support was our primary optimization tool.

  4. Test both sides of the bridge. Bugs in wasm-bindgen serialization are subtle. We maintain a comprehensive test suite that runs both in native Rust (via cargo test) and in-browser (via wasm-pack test).

Conclusion

Rust and WebAssembly have been the right choice for ChainSolve’s computation engine. The combination of memory safety, zero-cost abstractions, and near-native browser performance lets us deliver a real-time visual computation experience that would not be possible with JavaScript alone.

If you are considering Wasm for your next browser-based application, we hope this post provides useful context for your decision. And if you want to see the results in action, try building a computation graph at app.chainsolve.co.uk.