DIY rust on the web
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
Without frameworks, how does rust run on the web?
There’s essentially only 1 absolutely required crate, but an additional 2 are very useful.
-
wasm-bindgen - library and tools for generating bindings for the web
Then there are 2 crates for importing APIs,
Creating a bare bones project
This is the shortest amount of steps from empty directory to working web demo.
-
Create the project:
cargo init minimal_wasm_demo --lib -
Change the project to a dynamic library (in the
Cargo.toml):
[lib] crate-type = ["cdylib"]
-
add the wasm bindgen crate:
cargo add wasm-bindgen -
in the
src/lib.rsfile:-
add
use wasm_bindgen::prelude::wasm_bindgen;to the top. -
add
#[wasm_bindgen]before thepub fn add(left: u64, right: u64)function that was created for you in step 1. This is what tells thewasm-bindgenCLI tool that the adder function should be accessible from JavaScript.
-
-
cargo build --release --target wasm32-unknown-unknown -
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/pkgdirectory -
-
_.js- javascript bindings. -
_.d.ts- typescript header file for the wasm bindings. -
_.wasm- the wasm file! -
_.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:
-
Judge via objective benchmarks (in this case, file size)
-
Let the compiler/profiler tell you where to optimize (check each optimization to make sure each successive optimization helps rather than hurts!)
-
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
Link Time Optimization (LTO)
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:
-
avoiding the use of an allocator (and using
#[no_std]) -
profiling your code: https://rustwasm.github.io/book/reference/code-size.html#size-profiling
-
consider using trait objects over generics
References and Continued Learning
-
wasm-bindgen book (successor to the rustwasm project)
-
wasm-packis another useful CLI tool that can streamline a lot of the process used in this post. Install with:cargo install wasm-packor 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
)