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.
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:
-
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.
-
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. -
Step [counter(steps)] JavaScript Bridge
The bridge layer uses
wasm-bindgento expose a minimal API to JavaScript:create_graph(),add_block(),connect(),set_input(),evaluate(), andget_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:
| Workload | JS (ms) | Wasm (ms) | Speedup |
|---|---|---|---|
| 100 blocks, linear chain | 2.1 | 0.18 | 11.7x |
| 1,000 blocks, branching graph | 34.2 | 2.4 | 14.3x |
| 5,000 blocks, complex DAG | 412.0 | 22.1 | 18.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:
- The main thread sends graph mutations (block added, connection changed, input updated) as structured messages to the Worker.
- The Worker applies mutations to the Wasm engine and triggers re-evaluation.
- Results are sent back to the main thread via
postMessage. - For large result sets, we use
SharedArrayBufferwith Atomics to avoid the serialization cost ofpostMessage.
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:
-
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. -
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%.
-
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.
-
Test both sides of the bridge. Bugs in
wasm-bindgenserialization are subtle. We maintain a comprehensive test suite that runs both in native Rust (viacargo test) and in-browser (viawasm-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.