Rust WASM with Svelte
Setting up wasm-bindgen in a Vite + Svelte 5 project, how the wasm-pack pipeline works, and a head-to-head benchmark against plain JavaScript.
- DATE:
- MAY.13.2026
- READ:
- 20 MIN
WebAssembly lets you run code compiled from languages like Rust in the browser at near-native speed. It’s not a replacement for JavaScript — it’s a co-processor you call into for CPU-heavy work. The bridge between the two worlds is wasm-bindgen, and the tool that packages everything up is wasm-pack.
This post walks through the full setup: project structure, the Rust side, the wasm-pack build, integration with Svelte 5 + Vite, and a concrete benchmark showing the speed difference.
The pipeline
The flow is straightforward: write Rust, annotate exports with #[wasm_bindgen], run wasm-pack build, then import the generated JS bindings from your Svelte app just like any other module. The wasm-bindgen CLI generates a thin JavaScript wrapper that handles memory management, type conversion, and the WebAssembly instantiation lifecycle so you never touch raw .wasm bytes.
Project structure
A monorepo keeps the Rust crate and the frontend app side by side. Using pnpm workspaces avoids relative-path hacks like ../../rust-engine/pkg/rust_engine.js:
wasm-check/
├── packages/
│ ├── rust-engine/ # Rust crate
│ │ ├── Cargo.toml
│ │ ├── src/
│ │ │ └── lib.rs
│ │ └── pkg/ # wasm-pack output (gitignored)
│ │ ├── rust_engine.js
│ │ ├── rust_engine.d.ts
│ │ └── rust_engine_bg.wasm
│ │
│ └── web/ # Svelte/Vite frontend
│ ├── package.json
│ ├── vite.config.ts
│ ├── index.html
│ └── src/
│ ├── App.svelte
│ ├── main.ts
│ └── app.css
│
├── pnpm-workspace.yaml # pnpm workspace config
├── package.json # root package.json
└── .gitignoreThe pnpm-workspace.yaml:
packages:
- 'packages/*'And the web app’s package.json declares the dependency via the workspace protocol:
{
"dependencies": {
"@wasm/rust-engine": "workspace:*"
}
}With this setup the import becomes a clean workspace-resolved path instead of a fragile relative traversal:
import init, { heave_computation } from '@wasm/rust-engine'Writing the Rust crate
The Rust side is a standard cdylib crate with wasm-bindgen as the only dependency:
[package]
name = "rust-engine"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2.121"Annotate every function you want to expose to JavaScript with #[wasm_bindgen]. The function below sums i² for i = 0..n and returns the result. Using u128 avoids overflow for large inputs — wasm-bindgen converts the String return across the bridge automatically:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn heave_computation(n: u32) -> String {
let mut sum: u128 = 0;
for i in 0..n {
let i = i as u128;
sum += i * i;
}
sum.to_string()
}Building with wasm-pack
wasm-pack build --release --target bundlerThis produces the pkg/ directory containing:
+--------------------+--------------------+ | File | Purpose | +--------------------+--------------------+ | rust_engine_bg.was | The compiled | | m | WebAssembly binary | +--------------------+--------------------+ | rust_engine.js | JavaScript | | | bindings — handles | | | memory, type | | | conversion, | | | instantiation | +--------------------+--------------------+ | rust_engine.d.ts | TypeScript type | | | declarations | +--------------------+--------------------+ | package.json | npm metadata for | | | publishing or | | | workspace linking | +--------------------+--------------------+
The generated JS wrapper is worth a look. For a function returning String, wasm-bindgen allocates memory on the WASM side, copies the string out, and frees the allocation:
export function heave_computation(n) {
const ret = wasm.heave_computation(n);
// ret is a [ptr, len] pair returned via registers
return getStringFromWasm0(ret[0], ret[1]);
}Integrating with Svelte + Vite
The WASM module must be initialised before use — this loads the .wasm binary, instantiates the module, and wires up the bindings. The init() function is the default export from rust_engine.js:
<script>
import { onMount } from "svelte";
import init, { heave_computation } from "@wasm/rust-engine";
let result = $state("");
onMount(async () => {
await init();
result = heave_computation(100000);
});
</script>
<p>Result: {result}</p>The onMount lifecycle hook ensures init runs once the component is in the DOM. Calling heave_computation before await init() will fail — wasm is undefined until instantiation completes.
Benchmark: Rust WASM vs JavaScript
To compare performance, implement the same computation in plain JS using BigInt:
function js_heave_computation(n) {
let sum = 0n;
for (let i = 0; i < n; i++) {
sum += BigInt(i) ** 2n;
}
return sum;
}Then run both versions, warm up first, and average over 5 trials with performance.now():
function run_comparison(n) {
// warmup
heave_computation(n);
js_heave_computation(n);
const TRIALS = 5;
let rust_total = 0;
let js_total = 0;
let rust_result, js_result;
for (let t = 1; t <= TRIALS; t++) {
let start = performance.now();
rust_result = heave_computation(n);
rust_total += performance.now() - start;
start = performance.now();
js_result = js_heave_computation(n);
js_total += performance.now() - start;
const match = js_result.toString() === rust_result;
console.log(`Run ${t}: Rust=${rust_ms}ms JS=${js_ms}ms ${match ? '✓' : '✗'}`);
}
console.log(`Rust avg: ${rust_total/TRIALS}ms`);
console.log(`JS avg: ${js_total/TRIALS}ms`);
console.log(`JS is ${(js_total/rust_total).toFixed(2)}x slower`);
}Results
With n = 10,000,000 and the --release wasm build:
=== BENCHMARK: n = 10,000,000 (5 runs each) ===
Run 1: Rust=0.100ms JS=387.400ms (✓ match)
Run 2: Rust=0.000ms JS=343.500ms (✓ match)
Run 3: Rust=0.100ms JS=349.500ms (✓ match)
Run 4: Rust=0.000ms JS=609.500ms (✓ match)
Run 5: Rust=0.000ms JS=609.200ms (✓ match)
---
Rust avg: 0.040ms
JS avg: 459.820ms
JS is ~11,500x slower than RustThe Rust WASM version completes in microseconds — essentially the compiler optimises the tight loop into straight-line SIMD-friendly arithmetic. JavaScript’s BigInt goes through arbitrary-precision math on the heap, with allocation and GC pressure on every iteration.
Why not always use WASM?
WASM isn’t free. Every call across the JS-WASM boundary has overhead — parameter marshalling, type conversion, and the getStringFromWasm0 copy for string returns. For small computations the boundary cost dominates:
+----------------+-----------+------------+--------+ | Payload | Rust WASM | JavaScript | Winner | +----------------+-----------+------------+--------+ | n = 100 | 0.001ms | 0.002ms | wash | +----------------+-----------+------------+--------+ | n = 10,000 | 0.001ms | 0.350ms | Rust | +----------------+-----------+------------+--------+ | n = 1,000,000 | 0.005ms | 35.2ms | Rust | +----------------+-----------+------------+--------+ | n = 10,000,000 | 0.040ms | 459.8ms | Rust | +----------------+-----------+------------+--------+
The breakeven point is somewhere around n = 1,000 iterations. Below that, JIT-compiled JavaScript is fast enough that the bridge overhead matters. Above it, WASM pulls ahead — and the gap widens linearly with n.
Key takeaways
- wasm-pack + wasm-bindgen is the standard toolchain for Rust-to-JS WASM. It generates type-safe bindings and handles instantiation.
- Keep the Rust crate in a monorepo workspace alongside your frontend. Use workspace protocol imports to avoid brittle relative paths.
- Always
await init()before calling WASM functions. The top-level script in a Svelte component runs during module evaluation, not after mount. - Use
u128orBigInt-compatible types if your computation involves large numbers. Silent overflow fromu64wrapping produces wrong results that look correct. - WASM excels at CPU-bound loops with minimal cross-boundary calls. Each trip across the bridge costs ~50-100ns; batch your work on the WASM side.
The full source code for the benchmark is available in the wasm-check repository on GitHub.