Rust and WebAssembly do the work on this site. Svelte just renders it.

There are four Rust crates in the repository behind this site. Each one compiles to its own WebAssembly module, and the SvelteKit front end you're reading this on imports them like any other module. The animated background is Rust. The contribution calendar is Rust. Both of the apps under /apps are Rust with a Svelte shell around them.

None of that is a rewrite-it-in-Rust exercise. The interesting part is that the four crates exist for four genuinely different reasons — throughput, ecosystem, correctness, and privacy — and only one of them is about speed. "Use Rust for the browser" isn't a single argument, and treating it like one is why most posts about it end up unconvincing.

So here's the honest version: what's in here, what it measurably bought, what it cost, and the benchmark where WebAssembly loses badly.

What's actually in here

Four crates in one Cargo workspace, each built with wasm-pack and consumed by Svelte 5 / SvelteKit 2, deployed to Cloudflare.

Crate Job Rust LOC .wasm Gzipped Tests
ascii-bg Animated ASCII background 389 30.5 KB 13.8 KB
gh-calendar Lays out the GitHub contribution grid 315 20.2 KB 9.4 KB 10
codec 101 encode/decode algorithms behind /apps/codec 6,331 1,578.7 KB 811.6 KB 15
imgcodec 12-format image transcoder behind /apps/converter 1,201 1,544.5 KB 572.6 KB 14

The supporting cast is deliberately boring: wasm-pack driven by two Node scripts, Vite with vite-plugin-wasm, Svelte 5 with runes forced on, Tailwind v4, and @sveltejs/adapter-cloudflare.

Four crates, four different arguments for Rust

ascii-bg — raw per-frame throughput

The field behind the hero evaluates, per cell, a domain-warped fractal Brownian motion field: 2D simplex noise, four octaves, rotation between octaves, then a contour-banding pass.

Measured: 19,800 cells (220 × 90) in 53 ms, around 373 cells per millisecond. It redraws on a 150 ms interval, so it sits at roughly a third duty cycle.

The interesting bit is that 53 ms is expensive even in Rust, and that's precisely why it isn't in JavaScript. That number is already the optimised version — cells whose horizontal mask falls under the first glyph's threshold skip noise evaluation entirely, and rows outside the hero-to-sides changeover only evaluate one of the two fields instead of blending both.

There is no browser primitive for "domain-warped fbm". You can't polyfill your way out of it, you can't hand it to a native API, and it's pure arithmetic. Arithmetic is where WebAssembly earns its keep.

codec — algorithms JavaScript simply doesn't have

101 algorithms across eight categories: text (23), compression (18), obfuscation (15), base (13), escaping (12), integer (11), binary (6), and legacy code pages (3).

A handful have native equivalents — btoa, TextDecoder. Most categorically do not. Packed BCD, Shift JIS and the other legacy code pages, eighteen compression schemes, fifteen obfuscation formats.

The argument here isn't performance, it's the crates registry. Legacy code page tables are a solved problem in Rust — you add a dependency. In JavaScript you'd be hand-porting lookup tables or shipping a large library per family. The CJK tables are most of why that module is 1.5 MB, and I'd rather pay that once than maintain the tables myself.

imgcodec — one mature crate instead of a pile of JS libraries

This is the image crate with twelve format features enabled: png, jpeg, gif, webp, bmp, ico, tiff, tga, pnm, qoi, hdr, exr. In JavaScript that's several separate libraries of wildly varying quality, several of which would themselves be Emscripten-compiled C — so you'd be shipping WebAssembly anyway, just with more steps and less coherence.

The second argument matters more: it never touches the network. The browser hands over bytes, the module decodes, transforms, re-encodes, and hands them back. No upload, no server, no retention, no privacy policy to trust. That's a real property of the architecture rather than a marketing line, and WebAssembly is what makes it possible without a backend.

gh-calendar — correctness, not speed

This one is the odd one out, and it's worth being straight about it. Laying out 369 days on a 53×7 grid is not CPU-bound. JavaScript would do it instantly. Rust is there for exhaustive testability.

Ten unit tests cover the cases that actually break calendars: ragged first and last weeks (a year rarely starts on a Sunday), level clamping, month-label collisions, and empty input. All of them run under cargo test — no browser, no DOM, no test runner configuration, no waiting on a dev server.

That caught two real bugs that would have been miserable to find by staring at the rendered grid: a label-clearance rule that silently swallowed "Sep" when the year opened late in August, and an off-by-one in the trailing partial week.

Use Rust for the parts you want to prove correct, not just the parts you want to be fast. That's the most under-argued benefit of the whole approach, and it's the one I'd lead with if I were selling it to someone.

Is WebAssembly faster than JavaScript?

Not always. Here's the benchmark that makes the point, encoding 1 MB to base64 with all three outputs verified byte-identical:

Implementation Time Relative
Rust → wasm 6.10 ms baseline
Hand-written JS 65.46 ms 10.7× slower
Native btoa 1.56 ms 3.9× faster than wasm

Where the browser already has a native primitive, use it — WebAssembly will lose. btoa is browser-native C++ with no boundary crossing to pay for. No amount of Rust is going to beat that, and anyone claiming otherwise hasn't measured it.

Rust wins on the other hundred algorithms in that app for exactly the reason it loses this one: there is no native equivalent, so the real comparison is against the JavaScript you'd have to write yourself. That's the honest framing, and it's a much more defensible thesis than a generic speed claim:

WebAssembly isn't faster than the browser. It's faster than the JavaScript you'd have to write when the browser can't help you.

For scale, sustained codec throughput lands around 151 MB/s for base64 and 121 MB/s for hex.

Instantiation costs less than you'd think

Cold module init, approximate, one machine:

Module Size Init
ascii-bg 30 KB ~3.3 ms
gh-calendar 20 KB ~3.4 ms
imgcodec 1.5 MB ~8.9 ms
codec 1.6 MB ~9.3 ms

Note the shape: a 50× larger binary costs under 3× the init time. Instantiation is not the thing to worry about — download is.

Which is why code splitting matters more than any of these numbers. Loading each page fresh:

  • / pulls ascii_bg_bg.wasm and gh_calendar_bg.wasm only — about 51 KB on disk, 23 KB over the wire
  • /apps/converter pulls ascii_bg and imgcodec, and not codec

The 1.5 MB modules are only paid for by people who open the app that needs them. Vite handles this with no special configuration, because wasm-pack --target web output is just an ES module as far as the bundler is concerned.

Methodology, since it matters: these were measured on 2026-08-27 against the dev build on one desktop browser. Sizes are the on-disk .wasm for the tables above and gzipped where I've quoted download cost. Treat the timings as orders of magnitude, not a league table.

How the Svelte side consumes it

Three patterns do all the work.

Rust owns the catalogue, the UI knows nothing

Both apps expose a registry_json() that returns a complete description of what the engine can do — every algorithm, every option, every option's type, default, and the condition under which it should be shown:

#[wasm_bindgen]
pub fn registry_json() -> String {
    serde_json::to_string(&catalog::all()).unwrap_or_else(|_| "[]".into())
}

The Svelte side reads that and renders the form generically. Adding an algorithm means touching one Rust file — the picker, the option boxes, the validation, and the pane layout all follow automatically. No format knowledge is hardcoded in the front end at all.

This is the single best argument for the combination, and it has nothing to do with performance. Rust is where the domain model lives. Svelte renders whatever it's told. The seam sits where it naturally wants to sit instead of being renegotiated per feature.

One thin TypeScript wrapper per module

Between the raw bindings and the components sits a small wrapper. It memoises init so it happens exactly once regardless of which component asks first:

let ready: Promise<void> | undefined;

function ensure(): Promise<void> {
	ready ??= init().then(() => undefined);
	return ready;
}

It also converts JsError into a typed domain error (ImageError, CodecError), declares TypeScript interfaces mirroring the Rust structs, and keeps every cast and quirk out of the components. Components should never know they're talking to WebAssembly.

The boundary is typed arrays and JSON, nothing clever

Deliberately no serde-wasm-bindgen. Data crosses as:

  • &[u8] / Vec<u8>Uint8Array
  • Vec<i32>Int32Array
  • structured data ↔ a JSON string, parsed on the JS side
  • errors ↔ Result<T, JsError>, which arrives as an ordinary JS Error you can try/catch

That last one is worth knowing about: you don't need a custom error protocol. Return a Result and try/catch works.

The one place the boundary bites is ownership. gh-calendar returns a #[wasm_bindgen] struct with getters, and the Svelte side calls .free() in a finally block once it has read everything out. wasm-bindgen structs are not garbage collected for you.

The build pipeline

Most tutorials stop at wasm-pack build. Everything that makes this liveable happens after that.

One shared release profile

rust/Cargo.toml holds a single release profile for every crate in the workspace:

[profile.release]
opt-level = "z"       # optimise for size, not speed
lto = true
codegen-units = 1
panic = "abort"       # no unwinding machinery
strip = true
debug = false
incremental = false
overflow-checks = false

Then each crate adds wasm-opt passes that wasm-pack runs after the Rust build:

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--all-features", "--strip-debug", "--strip-producers", "--vacuum"]

panic = "abort" matters more than it looks. Panic formatting is what embeds source paths in the output binary — which leads directly to the next part.

The privacy step nobody mentions

scripts/build-wasm.mjs exists mostly for one reason: rustc bakes absolute filesystem paths into the binary. The crate directory, the toolchain sysroot, and the local cargo registry — which contains your machine's username. Ship that and you've published your home directory layout to anyone who runs strings on a public .wasm file.

The fix is --remap-path-prefix, one rule per source of leakage, ordered least-specific to most-specific because rustc applies the last matching rule:

cwd            → ""
rustc sysroot  → "rust"
CARGO_HOME     → "cargo"
registry src   → "crates"
rust/          → "src"

The script discovers the registry index directories at runtime, because their names embed a hash (index.crates.io-<hash>) that differs per machine.

I've not seen this mentioned in a single "Rust and WebAssembly" tutorial, and every one of them is telling people to publish binaries with their username in them.

Moving the output

scripts/move-wasm.mjs clears src/lib/wasm/<crate>/ and moves pkg/ into it. It clears first on purpose: a renamed export would otherwise leave a stale file behind that still type-checks, and you'd find out in production.

npm run build:wasm builds all four. npm run build:wasm -- codec builds one, which is what you actually use while iterating.

Why Rust and Svelte fit together

Some of this is genuine synergy. Some of it is Svelte having the good manners to stay out of the way.

  1. Both compile away. Rust compiles to wasm with no interpreter. Svelte compiles components to direct DOM operations — there's a small reactivity runtime in Svelte 5, but no virtual DOM and no component framework shipped to the browser. Neither one asks the user to download a machine to run your machine.

  2. Runes make wasm results ordinary state. A $state variable populated from an async wasm call needs no store, no effect hook, no dependency array. The contribution calendar's entire data path is fetch → init() → build() → assign to $state, and the DOM follows.

  3. No virtual DOM to fight over large outputs. The calendar renders 369 individually-classed spans, and the ASCII field replaces a multi-kilobyte text node several times a second. There's no reconciliation pass sitting in between deciding whether it agrees with you.

  4. The type story survives the boundary. wasm-pack emits .d.ts from the Rust signatures, so svelte-check type-checks calls into Rust. Rename an export and the Svelte build fails — which is exactly why move-wasm.mjs deletes the destination first.

  5. Vite treats wasm as a first-class module. vite-plugin-wasm plus --target web means import init, { fn } from '...' works in dev and in build, with normal code splitting and no special-casing.

What it costs

A post that only lists benefits doesn't deserve to be believed.

  • Binary size is the real tax. 1.5 MB for the image converter is a lot. It's justified by lazy loading and by replacing a stack of JavaScript libraries, but it isn't free. The opt-level = "z" / LTO / wasm-opt -Oz stack exists purely to stop it being worse.
  • Two toolchains. Contributors need Rust and wasm-pack on PATH, not just Node. That raises the floor for anyone wanting to submit a fix.
  • A build step between you and your change. Editing Rust means rebuilding before the browser sees it. A couple of seconds per crate, but it isn't HMR and you feel it.
  • Manual memory at the boundary. .free() on returned structs, or you leak.
  • Debugging is worse. panic = "abort" and strip = true mean a panic in production tells you nothing. Rust unit tests are the mitigation: you debug in cargo test, not in DevTools.
  • Boundary crossings aren't free. Chatty per-item calls will erase the gains entirely. Both apps are designed around one call per operation with the payload as a typed array.

Three gotchas that cost me time

Don't bake presentation into Rust. gh-calendar originally emitted a padded character grid that assumed a monospaced font. Switching the display face to Space Grotesk — proportional, where # is 7.54 px and a space is 3.08 px — would have sheared the entire calendar. The fix was to have Rust return columns and labels and let CSS decide widths. Rust owns the model, not the layout.

Uint8Array vs SharedArrayBuffer. TypeScript types a bare Uint8Array as possibly backed by a SharedArrayBuffer, which Blob refuses to accept. Modules built without threads never produce shared memory, so the narrowing is sound — do it once, in the wrapper, not in every component:

new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mime })

Host allowlisting is a deterrent, not a boundary. ascii-bg checks window.location.hostname and returns an empty grid anywhere else. The module is public and can be patched by anyone who cares to. It stops casual copy-paste and nothing more, and it's worth being clear-eyed about that rather than mistaking it for a security control.

If you want to try it

The minimal path, matching what's in this repo.

cargo install wasm-pack

Put a crate inside your SvelteKit project at rust/my-crate/, and give it both crate types:

[lib]
crate-type = ["cdylib", "rlib"]   # rlib so `cargo test` works too

cdylib is what produces the wasm. Adding rlib is what lets you keep #[cfg(test)] mod tests and run it natively. Don't skip it — for most projects that's where most of the value lives.

Export something, build it with wasm-pack build my-crate --target web --release, move pkg/ somewhere under src/lib/ so $lib can reach it, add wasm() to your Vite plugins, and call it:

<script lang="ts">
	import { onMount } from 'svelte';
	import init, { add } from '$lib/wasm/my-crate/my_crate';

	let result = $state<number | null>(null);

	onMount(async () => {
		await init();
		result = add(2, 3);
	});
</script>

That's the whole integration. Everything else in this post — the shared release profile, the wasm-opt passes, the path remapping, the memoised wrapper — is what you add once it's working and you've decided to keep it.

What I'd actually recommend

Don't rewrite anything. The failure mode here is deciding Rust is the answer and then going looking for questions.

Start with one small, pure, well-defined function, and pick it on the basis of testing rather than speed. Something you'd struggle to test properly in JavaScript, where a browser test would be slow and awkward and you'd end up not writing it. Let the cargo test experience sell you on the approach before the benchmarks do — that's the order it happened here, and gh-calendar is the crate that convinced me even though it's the one with no performance argument at all.

Then, when you do hit something genuinely CPU-bound, or something the browser has no primitive for, you'll already have the pipeline in place and it'll be a twenty-line change instead of an architecture decision.

And when the browser does have a primitive — use it. btoa will beat your Rust every time, and pretending otherwise is how this whole approach gets a reputation it doesn't deserve.