<< BACK

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

+ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +
WASM build & deploy pipelineRUST SOURCEsrc/lib.rsCargo.tomlwasm-pack build--target bundler--releasePKG OUTPUTrust_engine_bg.wasmrust_engine.js + .d.tsSVELTE APPimport + await init()BROWSERWASM instantiatedwasm-bindgen bridge// Rust — annotated with #[wasm_bindgen]#[wasm_bindgen]pub fn heave_computation(n: u32) -> String// u128 arithmeticautobinds// Generated JS — calls into WASMexport function heave_computation(n) {return wasm.heave_computation(n);}
+ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +

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
└── .gitignore
++

The 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 bundler
++

This 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 Rust
++

The 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 u128 or BigInt-compatible types if your computation involves large numbers. Silent overflow from u64 wrapping 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.