Rust to WebAssembly: 8 Build Optimizations That Cut a 1.2MB Binary Down to 47KB

Rust to WebAssembly in production: cut binary size from 1.2MB to 47KB with these 8 proven optimization techniques. Code examples included.

Rust to WebAssembly: 8 Build Optimizations That Cut a 1.2MB Binary Down to 47KB

The first time I compiled Rust to WebAssembly, I got a 1.2 MB file and a browser that took three seconds before anything appeared on screen. I closed the tab and went back to plain JavaScript for two weeks. Eventually I got curious about what the build was actually doing, and the very same source code came out at 47 KB and started in under 40 milliseconds. Nothing in my Rust changed. Only the way I built it changed.

That lesson is the whole reason I’m writing this. Rust and WebAssembly fit together beautifully, but the defaults are tuned for correctness, not for your users. Once you know which eight levers matter, you can ship something small and quick without becoming a compiler engineer.

I’ll walk through every one of them the way I wish someone had walked me through it, slowly, with real code you can paste and run.

Setting up the project so nothing surprises you later

I used to fight with hand-written Cargo configurations. Then I started using wasm-pack, and the fighting stopped. It installs like this.

cargo install wasm-pack

Then you can either scaffold a fresh project or wire it into something you already have. Scaffolding looks like this.

wasm-pack new hello-wasm
cd hello-wasm

If you’re adding WASM to an existing crate, the only thing you truly need in your Cargo.toml is the crate type. This one line is where most beginners get stuck, because without it Rust builds a normal library and WebAssembly has nothing to export.

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"

The cdylib part tells Rust to produce a dynamic library that WebAssembly can load. The rlib part keeps the crate usable from other Rust code, which matters if you ever want to write tests that run natively.

Now the part that confused me for months: build targets. wasm-pack can produce output for three different worlds, and picking the wrong one causes errors that read like riddles.

wasm-pack build --target web        # for direct <script type="module"> use
wasm-pack build --target bundler    # for webpack, Rollup, Vite's bundler mode
wasm-pack build --target nodejs     # for Node.js and server-side tooling

With the web target, you must call and await the default export before touching anything else. That’s because the browser fetches and instantiates the module asynchronously, and the generated JavaScript wrapper can’t guess when that finished.

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Hello WASM</title></head>
<body>
<script type="module">
  import init, { greet } from './pkg/hello_wasm.js';
  await init();
  greet('World');
</script>
</body>
</html>

With the bundler target, the bundler handles that step for you and you can call greet directly. If you paste web target code into a webpack project and get “undefined is not a function”, this is why.

Making the binary small, because size is speed

A 1.2 MB module isn’t slow because WebAssembly is slow. It’s slow because the browser downloads it, compiles it, and validates it before a single line runs. Every kilobyte you remove is time your users get back.

The first lever is a release profile. By default, Cargo optimizes for speed, which often means more code. You want to optimize for size.

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

opt-level = "z" asks the compiler to prioritize small output. lto = true enables link-time optimization so the compiler can throw away functions nothing calls. codegen-units = 1 gives the optimizer the whole crate as one unit at the cost of slower builds, which is a trade I’ll always take for a production artifact. panic = "abort" removes the unwinding machinery that WebAssembly doesn’t really use anyway. strip = true throws away symbol names.

That combination alone took my 1.2 MB down under 300 KB.

The second lever is the allocator. Rust ships with a general-purpose allocator that’s larger than most WASM modules need. A lightweight one drops in with two lines.

use wee_alloc::WeeAlloc;

#[global_allocator]
static ALLOC: WeeAlloc = WeeAlloc::INIT;

One honest caveat: wee_alloc hasn’t been actively maintained for a while, and I’ve seen it fragment badly in long-running modules that allocate and free constantly. For short-lived page components it’s great. For something that runs for hours in an edge worker, I now keep the default allocator and find size elsewhere. Measure before you commit.

The third lever is wasm-opt, a binary optimizer that runs on the finished .wasm file rather than on your Rust. wasm-pack runs it automatically for release builds, but you can run it yourself with more aggression.

wasm-opt -Oz -o pkg/hello_wasm_bg.wasm pkg/hello_wasm_bg.wasm

To find out where the remaining bytes live, use twiggy. It prints the heaviest functions in your binary, which is usually humbling.

cargo install twiggy
twiggy top pkg/hello_wasm_bg.wasm
twiggy monos pkg/hello_wasm_bg.wasm

The last time I ran that, the top offender was Rust’s formatting machinery, dragged in by a single format! call in an error path. I replaced it with a &'static str and saved 22 KB. That’s the kind of honesty a size profiler gives you.

Talking between Rust and JavaScript in both directions

wasm-bindgen is the translator. It reads your Rust, understands which functions you want visible, and writes JavaScript glue plus type definitions that make everything feel native.

Exporting Rust to JavaScript is the easy direction.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[wasm_bindgen]
pub struct Counter {
    count: i32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    pub fn increment(&mut self) -> i32 {
        self.count += 1;
        self.count
    }

    pub fn value(&self) -> i32 {
        self.count
    }
}

From JavaScript that becomes a real class you can instantiate with new Counter(). Methods that take &mut self work because JavaScript holds a pointer to the Rust struct, not a copy. This is one of those details that feels like magic until it bites you, and it bites you when you forget to call .free() and your memory grows forever.

Going the other direction — calling JavaScript from Rust — is where people get tripped up, because Rust has no idea what a console is. You declare the shape of the JavaScript you expect, and wasm-bindgen trusts you.

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);

    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);

    #[wasm_bindgen(js_name = trackEvent)]
    fn track_event(name: &str, value: i32);
}

Now log("hello") inside Rust calls console.log, and track_event calls a global trackEvent function that your page defines. There’s no runtime lookup and no reflection; the glue calls the name directly.

For callbacks that need to live past the current function, wrap them in a Closure. The tricky part is ownership: JavaScript now holds a reference, and Rust will drop the closure when it goes out of scope unless you hand ownership over.

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

#[wasm_bindgen]
pub fn watch_clicks(element_id: &str) -> Result<(), JsValue> {
    let document = web_sys::window().unwrap().document().unwrap();
    let element = document.get_element_by_id(element_id).unwrap();

    let callback = Closure::<dyn FnMut()>::new(move || {
        web_sys::console::log_1(&"clicked".into());
    });

    element.add_event_listener_with_callback(
        "click",
        callback.as_ref().unchecked_ref(),
    )?;

    callback.forget(); // the listener owns it now
    Ok(())
}

forget() intentionally leaks the closure so it survives. That’s correct here, and wrong everywhere else. If you add and remove listeners in a loop, you’ll leak one closure per iteration, and your tab will eat a gigabyte of RAM before lunch. Keep the closure around and call drop() when you remove the listener.

Memory, and why strings cost you

WebAssembly’s memory is one big flat buffer. Rust owns it. JavaScript can look at it but can’t put objects in it. So every string, every array, every object that crosses the boundary gets copied, and copies cost time.

When you pass a string from JavaScript into a Rust function, wasm-bindgen allocates space in the module’s memory, copies the UTF-8 bytes in, calls your function, then frees the space. When Rust returns a string, the reverse happens. For small strings this is invisible. For a 10 MB file upload, it’s the whole story.

Here’s a function that takes a byte slice. It looks free, but it isn’t.

#[wasm_bindgen]
pub fn checksum(data: &[u8]) -> u32 {
    let mut sum: u32 = 0;
    for byte in data {
        sum = sum.wrapping_mul(31).wrapping_add(*byte as u32);
    }
    sum
}

Calling checksum(new Uint8Array(10_000_000)) from JavaScript copies ten megabytes into WASM memory before your loop starts. If you control the buffer on the Rust side, you can skip the copy entirely by handing JavaScript a pointer and a length.

#[wasm_bindgen]
pub struct Buffer {
    data: Vec<u8>,
}

#[wasm_bindgen]
impl Buffer {
    #[wasm_bindgen(constructor)]
    pub fn new(len: usize) -> Buffer {
        Buffer { data: vec![0; len] }
    }

    pub fn ptr(&self) -> *const u8 {
        self.data.as_ptr()
    }

    pub fn len(&self) -> usize {
        self.data.len()
    }

    pub fn checksum(&self) -> u32 {
        let mut sum: u32 = 0;
        for byte in &self.data {
            sum = sum.wrapping_mul(31).wrapping_add(*byte as u32);
        }
        sum
    }
}

On the JavaScript side you build a view straight onto WebAssembly memory and write into it.

const buf = new Buffer(10_000_000);
const memory = wasmMemory.buffer;
const view = new Uint8Array(memory, buf.ptr(), buf.len());

for (let i = 0; i < view.length; i++) view[i] = i & 0xff;

console.log(buf.checksum());

Two warnings here, and both of them have burned me. First, any view you build is invalidated the moment WebAssembly memory grows, because the underlying ArrayBuffer gets detached and replaced. If you hold a view across an allocation, you’ll write into nothing. Rebuild the view after every call that might allocate.

Second, ptr() is only valid while the Buffer is alive. Call .free() and the pointer becomes a dangling address pointing at memory that will be reused for something else. Treat raw pointers as valid for exactly one synchronous block of code.

Using browser APIs without writing JavaScript

The web-sys crate is a set of bindings for basically everything the browser exposes: the DOM, fetch, canvas, WebGL, timers, the clipboard, Web Audio, and a thousand other things. It doesn’t include them all by default, because that would be enormous. You opt in per feature.

[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = ["Window", "Document", "Element", "HtmlElement", "console"]

Then you can build a page from Rust.

use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> {
    let window = web_sys::window().ok_or("no window")?;
    let document = window.document().ok_or("no document")?;
    let body = document.body().ok_or("no body")?;

    let heading = document.create_element("h1")?;
    heading.set_text_content(Some("Rendered from Rust"));

    let button = document.create_element("button")?;
    button.set_text_content(Some("Click me"));
    button.set_attribute("id", "my-button")?;

    body.append_child(&heading)?;
    body.append_child(&button)?;

    Ok(())
}

The #[wasm_bindgen(start)] attribute means this runs automatically when the module loads, which is a nice way to bootstrap a whole app.

One thing about web-sys that took me a while: everything returns Result or Option, because every DOM call is technically fallible, and Rust refuses to pretend otherwise. It reads verbose at first. After a week it reads honest.

Async, and the fact that there are no threads

WebAssembly in the browser is single-threaded. There’s no tokio, no std::thread, no blocking. Any time you wait for something, you’re yielding to the browser’s event loop. That sounds limiting until you realize async and await in Rust map directly onto JavaScript promises, and it all just works.

Start with the dependency.

[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

[dependencies.web-sys]
version = "0.3"
features = ["Window", "Response", "Request", "RequestInit"]

Then write an async function that fetches and parses.

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;

#[wasm_bindgen]
pub async fn fetch_text(url: &str) -> Result<String, JsValue> {
    let window = web_sys::window().ok_or("no window")?;
    let promise = window.fetch_with_str(url);

    let response_value = JsFuture::from(promise).await?;
    let response: web_sys::Response = response_value.dyn_into()?;

    if !response.ok() {
        return Err(format!("HTTP {}", response.status()).into());
    }

    let text_promise = response.text()?;
    let text_value = JsFuture::from(text_promise).await?;

    Ok(text_value.as_string().unwrap_or_default())
}

From JavaScript, fetch_text returns a promise, so you await it like any other async function. The JsFuture::from call is the bridge: it turns a JavaScript promise into something Rust’s .await understands.

If you need to kick off async work without awaiting it from JavaScript, use spawn_local.

use wasm_bindgen_futures::spawn_local;

#[wasm_bindgen]
pub fn start_polling(url: String) {
    spawn_local(async move {
        loop {
            match fetch_text(&url).await {
                Ok(text) => web_sys::console::log_1(&text.into()),
                Err(err) => web_sys::console::log_1(&err),
            }
            gloo_timers::future::TimeoutFuture::new(5_000).await;
        }
    });
}

That gloo_timers line is a much nicer sleep than wrestling with setTimeout and closures by hand, and I reach for it constantly.

One trap worth naming: never call a blocking executor like futures::executor::block_on inside WebAssembly. It blocks the only thread you have, so the promise you’re waiting on can never resolve, and your page freezes forever. If your module hangs with no error, this is almost always why.

Debugging, which is better than you’d expect

My first Rust panic in the browser showed up as RuntimeError: unreachable executed, with a stack trace pointing at line 1 of some generated JavaScript. That was useless. Two changes fixed it.

The first is a panic hook that turns Rust panics into readable console errors.

[dependencies]
console_error_panic_hook = "0.1"
#[wasm_bindgen(start)]
pub fn init() {
    console_error_panic_hook::set_once();
}

Now a panic prints the actual message and a Rust stack trace instead of unreachable.

The second change is building in dev mode with debug info kept.

wasm-pack build --dev

In a release profile, strip = true and aggressive optimization throw away exactly the information you need to debug. In a dev build, the symbols stay. With the right browser settings, Chrome and Firefox will show your Rust source files directly in the Sources panel, and you can set breakpoints in the Rust code and step through it line by line. I still find that slightly unbelievable.

For logging, web_sys::console::log_1 works, but I prefer a small macro so I don’t have to write &"text".into() a hundred times.

macro_rules! log {
    ($($t:tt)*) => {
        web_sys::console::log_1(&format!($($t)*).into())
    };
}

There’s a real cost, since format! pulls in formatting machinery and adds kilobytes. In development it’s worth every byte. In a release build, consider compiling it out with #[cfg(debug_assertions)].

Packaging it for npm so other people can use it

Everything up to now produces a folder of files. The last step is making that folder installable.

wasm-pack build --release --target bundler --scope myorg
wasm-pack pack
wasm-pack publish

The --scope flag namespaces the package under your npm organization. pack builds the tarball locally so you can inspect it before anything goes public, which I always do, because the first time I published I accidentally shipped a 40 MB debug build.

The generated package.json usually needs a couple of edits. This is the version I use.

{
  "name": "@myorg/hello-wasm",
  "version": "0.1.0",
  "files": [
    "hello_wasm_bg.wasm",
    "hello_wasm.js",
    "hello_wasm.d.ts",
    "hello_wasm_bg.wasm.d.ts"
  ],
  "main": "hello_wasm.js",
  "module": "hello_wasm.js",
  "types": "hello_wasm.d.ts",
  "sideEffects": false
}

That sideEffects: false line matters more than it looks. It tells webpack and Rollup that importing this package has no side effects, which lets their tree-shaking remove the parts you don’t call. Without it, bundlers keep everything.

If you’re using Vite, the bundler target works with a plain import.

import { Counter, add } from '@myorg/hello-wasm';

const counter = new Counter();
counter.increment();
console.log(add(2, 3), counter.value());

If you’re using the web target instead, you need to await initialization before anything else, and the import shape is different.

import init, { Counter } from '@myorg/hello-wasm';

await init();
const counter = new Counter();

One last note on .free(). Every exported Rust struct that JavaScript creates holds memory that Rust’s garbage collector will never touch, because Rust doesn’t have one. If you create a thousand Counter objects and never free them, you have a leak. Call .free() when you’re done, or wrap the object in a class with a dispose method so it’s obvious to whoever reads your code next.

What I wish I’d known at the start

The eight techniques aren’t really eight separate things. They’re one habit: understand what the boundary costs. The boundary between JavaScript and Rust is where the speed lives and where the bugs live, and everything above — the small binaries, the careful string handling, the async functions, the freed pointers — is about keeping that boundary cheap and obvious.

If I were starting today, I’d build the smallest possible thing first. One function, one button, one console log. Get the toolchain working. Then add the size optimizations and measure each one. Then add one async call. Then publish a private package to your own registry and install it somewhere else. Each step takes an hour and removes a whole category of confusion.

The 1.2 MB file that scared me off all those years ago turned out to be entirely fixable. What was missing wasn’t talent. It was a list of levers, and now you have one.


// Keep Reading

Similar Articles

Mastering Rust's Lifetime System: Boost Your Code Safety and Efficiency
Rust

Mastering Rust's Lifetime System: Boost Your Code Safety and Efficiency

Rust's lifetime system enhances memory safety but can be complex. Advanced concepts include nested lifetimes, lifetime bounds, and self-referential structs. These allow for efficient memory management and flexible APIs. Mastering lifetimes leads to safer, more efficient code by encoding data relationships in the type system. While powerful, it's important to use these concepts judiciously and strive for simplicity when possible.

Read Article →