Rust and WASM

Rust as a low-level language makes it a great candidate for WASM compilation. There’s no garbage collector to contend with, but there’s also many fewer footguns than, say, C++ (or so I hear). Rust can easily compile to wasm32 targets, has strong typing, has a great DevX, and is high performance. It is also just a lot nicer to work with than JavaScript (js), at least in my opinion.

The wasm-bindgen book is fantastic (and I borrow many ideas from that book here), but it skips a couple steps and some explanation so this blog post can help go over starting a rust-wasm project from scratch.

WASM in 2026

WASM in (early) 2026 means a developer must accept the following:

  • no direct DOM access from WASM

  • single-threaded only

  • high performance (low overhead) js-wasm function calls

  • lower performance js-wasm data passing (all objects get re-encoded)

  • 32-bit architecture

Getting started

You can follow along and see working examples at https://codeberg.org/humble_proton/blog-demos

Requirements

  • rust and cargo toolchain installed (go to rustup.rs)

  • wasm-bindgen-cli installed: cargo binstall wasm-bindgen-cli

    • binstall and install are interchangeable but binstall is better if you have it installed[1]

  • wasm32-unknown-unknown target available. I.e. rustup target add wasm32-unknown-unknown

Without frameworks, how does rust run on the web?

There’s essentially only 1 absolutely required crate, but an additional 2 are very useful.

  1. wasm-bindgen - library and tools for generating bindings for the web

Then there are 2 crates for importing APIs,

  1. web-sys - imports for all of the Web APIs. Things like File, Clipboard, HtmlElement, Window to name a few.

  2. js-sys - imports for all the JS APIs, like Promise, Array, BigInt, etc.

Creating a bare bones project

This is the shortest amount of steps from empty directory to working web demo.

  1. Create the project: cargo init minimal_wasm_demo --lib

  2. Change the project to a dynamic library (in the Cargo.toml):

[lib]
crate-type = ["cdylib"]
  1. add the wasm bindgen crate: cargo add wasm-bindgen

  2. in the src/lib.rs file:

    1. add use wasm_bindgen::prelude::wasm_bindgen; to the top.

    2. add #[wasm_bindgen] before the pub fn add(left: u64, right: u64) function that was created for you in step 1. This is what tells the wasm-bindgen CLI tool that the adder function should be accessible from JavaScript.

  3. cargo build --release --target wasm32-unknown-unknown

  4. wasm-bindgen target/wasm32-unknown-unknown/release/minimal.wasm --out-dir target/pkg --typescript --target web - This generates the bindings and "glue" code to/from js.

Success!

You now have 4 files in your target/pkg directory
  1. _.js - javascript bindings.

  2. _.d.ts - typescript header file for the wasm bindings.

  3. _.wasm - the wasm file!

  4. _.wasm.d.ts - the typescript header file for the wasm functions.

Exploring the results

To avoid CORS problems, you must use a simple web server.

python3 -m http.server

or if you don’t have python3 installed:

cargo binstall miniserve
miniserve . --index "index.html" -p 8000

The console window will show 1 + 2 = 3 if everything is working correctly.

Improving file sizes

The above example results in a 1.2KB wasm file and 3.8KB js file. We can do things ranging from very easy to not-very-easy to optimize the artifacts.

File size is not the only metric that matters, but it usually is the most important. Having a function execute a few more bytecode-level instructions is not humanly perceptible unless you are doing a ton of processing, so sacrificing a few milliseconds of execution time is better than sacrificing many milliseconds in just http transit time[2].

Furthermore, some optimizations don’t even affect the performance of the wasm object at all, and everyone must download your wasm file to their browser to do anything with it.

Lastly, size optimizations are like any other kind of software optimization:

  1. Judge via objective benchmarks (in this case, file size)

  2. Let the compiler/profiler tell you where to optimize (check each optimization to make sure each successive optimization helps rather than hurts!)

  3. Consider what you lose by optimizing (this is typically "readability", but in this case you probably aren’t reading the WAT[3] version of your wasm binary, so this shouldn’t matter as much)

Step 0 - gzip

Nearly every web server uses gzip to compress data before sending it to the client, so this should be essentially automatic. Check that your server does this and call it a day.

For example, we are now left with (roughly) an 844 byte wasm file. That’s about a savings of 30% in this case.

Step 1 - compilation options

At the expense of longer compilation times, we can compile with LTO enabled which improves memory layout and locality (among many other things:[4]

Add:

[profile.release]
lto = true

to the Cargo.toml.

Optimize for size over speed

We can also inform LLVM to compile rust with overall layout optimizations. There are two main options for opt-level:

  • "s": optimize for binary size

  • "z": optimize for binary size, but also turn off loop vectorization (i.e. more aggressive, performance impacts)

Loop vectorization can also increase file size in some cases, so remember point #2 of optimizations mentioned above, check each compiler option!

Add:

[profile.release]
opt-level = "z"

to the Cargo.toml.

Results

So we are left with:

[profile.release]
lto = true
opt-level = "z"

in the Cargo.toml.

This results in a 655 Byte wasm file (939 Bytes before gzip compression). For this very bare-bones project, there are no loops so there’s no difference between s and z optimization levels.

Step 2 - wasm-opt

Installation

Another tool we can use is the wasm-opt tool, available from the "binaryen" project. Download the appropriate release from their latest GitHub releases and expand it to a known place on your system (I put mine at $HOME/Applications/binaryen), then add the bin folder to your $PATH (if you are unsure what this means, see this StackOverflow answer).

I’m using version 128 for the purposes of this post:

$ wasm-opt --version
wasm-opt version 128 (version_128)

Usage

wasm-opt --help explains everything, but a short synopsis is:

# Optimize for size.
wasm-opt -Os -o output.wasm input.wasm

# Optimize aggressively for size.
wasm-opt -Oz -o output.wasm input.wasm

# Optimize for speed.
wasm-opt -O -o output.wasm input.wasm

# Optimize aggressively for speed.
wasm-opt -O3 -o output.wasm input.wasm

Results

This should be ran after wasm-pack.

We end up with a 611 Byte wasm file (800 Bytes before gzip compression).

Optimization summary

So, we can shave off a fair amount of code size with just running some basic commands.

We started with just a --release build and wasm-pack resulted in a 1.2KB wasm file. After changing the compiler flags, and running wasm-opt we are left with an 800 Byte wasm file, that’s roughly 35% size for free![5] Gzip compression (added by the server automatically) compresses this even further.

Note that these optimizations were chosen because they didn’t require any changes to our code, no restrictions to rust features, etc.

There are more…​ advanced optimizations we can perform, but that is outside the scope of this blog post.

If you want to look into them yourself, try:

References and Continued Learning

  • wasm-bindgen book (successor to the rustwasm project)

  • wasm-pack is another useful CLI tool that can streamline a lot of the process used in this post. Install with: cargo install wasm-pack or follow their directions.

Addendum

This is the WAT version of the result from step 2 of the optimizations looks like:

(module
 (type $0 (func))
 (type $1 (func (param i64 i64) (result i64)))
 (import "./minimal_bg.js" "__wbindgen_init_externref_table" (func $fimport$0))
 (memory $0 17)
 (data $0 (i32.const 1048576) "\c0\00/Users/humble_proton/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-0.2.114/src/externref.rs\00/rust/deps/dlmalloc-0.2.11/src/dlmalloc.rs\00RefCell already borrowedassertion failed: psize >= size + min_overhead\00r\00\10\00*\00\00\00\b1\04\00\00\t\00\00\00assertion failed: psize <= size + max_overhead\00\00r\00\10\00*\00\00\00\b7\04\00\00\r\00\00\00\02\00\10\00o\00\00\00\7f\00\00\00\11\00\00\00\02\00\10\00o\00\00\00\8c\00\00\00\11")
 (data $1 (i32.const 1048916) "\04")
 (table $0 1024 externref)
 (export "memory" (memory $0))
 (export "add" (func $0))
 (export "__wbindgen_externrefs" (table $0))
 (export "__wbindgen_start" (func $fimport$0))
 (func $0 (param $0 i64) (param $1 i64) (result i64)
  (i64.add
   (local.get $0)
   (local.get $1)
  )
 )
 ;; custom section "producers", size 114
 ;; features section: mutable-globals, nontrapping-float-to-int, bulk-memory, sign-ext, reference-types, multivalue, bulk-memory-opt, call-indirect-overlong
)

1. You should use cargo binstall …​ as a drop-in replacement for cargo install, as cargo-binstall is a LOT faster in most circumstances and will still revert to compiling from source if the pre-compiled binaries aren’t available.
3. WAT or WebAssembly Text Format is like an assembly version of your .wasm binary, see the Addendum for an example from this blog post.
4. Wikipedia and the LLVM LTO page have more info about what LTO does. In short, a lot and there’s a good case to be made that all production software should have LTO enabled.)
5. Our final result may have slightly worse performance (or it might not).