Iâve been writing an increasing amount of Rustâbased Wasm over the past few years. The internet has many opinions about Wasm, and wasm-bindgen is â letâs say â not universally beloved, but as I get more experience with it and learn how to work around its shortcomings, Iâve found some patterns that have dramatically improved my relationship with it.
I want to be clear up front about two things:
- I deeply appreciate the work of the wasm-bindgen maintainers.
- Itâs entirely possible that there are better ways to work with bindgen than presented here; this is just whatâs worked for me in practice!
Iâve seen excellent programmers really fight with bindgen. I donât claim to have all the answers, but this post documents a set of patterns that have made Rust+Wasm dramatically less painful for me.
TL;DR
Unless you have a good reason not to:
- Pass everything over the Wasm boundary by
&reference - Prefer
Rc<RefCell<T>>orArc<Mutex<T>>1 over&mut - Do not derive
Copyon exported types - Use
wasm_refgenfor any type that needs to cross the boundary in a collection (Vec, etc) - Prefix all Rust-exported types with
Wasm*and set thejs_name/js_classto the unprefixed name - Prefix all JS-imported types with
Js* - Implement
From<YourError> for JsValueusingjs_sys::Errorfor all of Rust-exported error types
Some of these may seem strange without further explanation. Below give more of the rationale in detail.
A Quick Refresher
wasm-bindgen generates glue code that lets Rust structs, methods, and functions be called from JS/TS. Some Rust types have direct JS representations (those implementing IntoWasmAbi); others live entirely on the Wasm side and are accessed through opaque handles.
Wasm bindings often look something like this:
#[wasm_bindgen(js_name = Foo)]
pub struct WasmFoo(RustFoo)
#[wasm_bindgen(js_name = Bar)]
pub struct WasmBar(RustBar)Conceptually, JS holds tiny objects that look like { __wbg_ptr: 12345 }, which index into a table on the Wasm side that owns the real Rust values.
The tricky part is that youâre juggling two memory models at once:
- JavaScript: garbageâcollected, reâentrant, async
- Rust: explicit ownership, borrowing, aliasing rules
Bindgen tries to help, but it both underâ and overâfits: some safe patterns are rejected, and some straight-up footguns are happily accepted. Ultimately, everything that crosses the boundary must have some JS representation, so it pays to be cognisant about what that representation is.
flowchart TD subgraph JavaScript subgraph Foo jsFoo["{ __wbg_ptr: 42817 }"] end subgraph Bar jsBar["{ __wbg_ptr: 71902 }"] end end subgraph Wasm subgraph table[Boundary Table] objID1((42817)) --> WasmFoo subgraph WasmFoo arc1[RustFoo] end objID2((71902)) --> WasmBar subgraph WasmBar arc2[RustBar] end end end jsFoo -.-> objID1 jsBar -.-> objID2
Should You Write Manual Bindings?
My take on most things is âyou do youâ, and this one is very much a matter of taste. I see a fair amount of code online that seems to prefer manual conversions with js_sys. This is a reasonable strategy, but I have found it to be time consuming and brittle. If you change your Rust types, the compiler isnât going to help you when youâre manually calling dyn_into to do runtime checks. Bindgen is going to insert the same runtime checks either way, but if you lean into its glue (including with some of the patterns presented here), you can get much better compile-time feedback.
Names Matter
Itâs an old joke that the two hardest problems in computer science are naming, caching, and off-by-one-errors. Naming is extremely important for mental framing and keeping track of whatâs happening, both of which can be a big source of pain when working with bindgen. As a rule, I use the current naming conventions:
IntoWasmAbi Types
QUOTE
Trait
IntoWasmAbi[âŠ] A trait for anything that can be converted into a type that can cross the Wasm ABI directly.
â Source
These are the primitive types of Wasm, such as u32, String, Vec<u8>, and so on. They get converted to/from native JS and Rust types when they cross the boundary. We do not need to do anything to these types.
Rust-Exported Structs Get Wasm*
This is where youâll usually spend most of your time. Wrapping Rust enums and structs in newtypes to re-expose them to JS is the bread and butter of Wasm. These wrappers get prefixed with Wasm* to help distinguish them from JS-imported interfaces, IntoWasmAbi types, and plain Rust objects. On the JS-side we can strip the Wasm, since it will only get the one representation, and (if done correctly) the JS side generally doesnât need to distinguish where a type comes from.
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub enum StreetLight {
Red,
Yellow,
Gree,
}
#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq)]
#[wasm_bindgen(js_name = StreetLight)]
pub struct WasmStreetLight(StreetLight)
#[wasm_bindgen(js_class = StreetLight)]
impl WasmStreetLight {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self(StreetLight::Red)
}
// ...
}On the JS side, thereâs only one StreetLight, so the prefix disappears. On the Rust side, the prefix keeps exported types visually distinct from:
- Plain Rust types
- JSâimported interfaces
IntoWasmAbivalues
JS-Imported Interfaces Get Js*
Any interface brought into Rust via extern "C" get a duck typed interface (by default). These pass over the boundary without restriction, which makes them a very helpful escape hatch ___.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = logCurrentTime)]
pub fn js_log_current_time(timestamp: u32);
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = Hero)]
type JsCharacter;
#[wasm_bindgen(method, getter, js_name = hp)]
pub fn js_hp(this: &JsCharacter) -> u32;
}
// Elsewhere
const gonna_win: bool = maelle.js_hp() != 0Duck typing is really helpful for cases where you want to expose a Rust trait to JS: as long as your Rust-exported type implements the interface, you can accept your Rust-exported type a JS-imported type, while retaining the ability to replace it with JS-imported types. A concrete example is if youâre exporting a storage interface, you likely have a default Rust implementation, but want extensibility if downstream devs want to give it an IndexedDB or S3 backend.
NOTE
Weâre going to abuse this âduck typed JS-imports on a Rust-exportâ later for
wasm-refgen.
The main gotchas with this approach are that 1. itâs brittle if the interface changes, and 2. if you donât prefix your methods on the Rust-side with js_*, you can run into namespace collisions (hence why I recommend prefixing these everywhere by convention). As an added bonus, this makes you very aware of where youâre making method calls over the Wasm boundary.
Donât Derive Copy
Copy makes it trivially easy to accidentally duplicate a Rust value that is actually a thin handle to a resource, resulting in null pointers. Just make a habit of avoiding it on exported wrappers. This can be a hard muscle memory to break since we usually want Copy wherever possible in normal Rust code.
Copy is only acceptable when exporting wrapping around pure data that has IntoWasmAbi, never for handles. I chalk this up as an optimisation; default to non-Copy unless youâre really sure itâs okay.
Avoiding Broken Handles
Try as it might, wasm-bindgen is unable to prevent handles breaking at runtime. A common culprit is passing an owned value to Rust:
#[wasm_bindgen(js_class = Foo)]
impl WasmFoo {
#[wasm_bindgen(js_name = "doSomething")]
pub fn do_something(&self, bar: Bar) -> Result<(), Error> {
// ...
}
#[wasm_bindgen(js_name = "doSomethingElse")]
pub fn do_something_else(&self, bars: Vec<Bar>) -> Result<(), Error> {
// ...
}
}If you do the above, it will of course consume your Bar(s), but since this goes over the boundary you get no help from the compiler about how you manage the JS side! The object will get freed on the Rust side, but you still have a JS handle that now points to nothing. You might say something like âso much for memory safetyâ, and you wouldnât be wrong.
Why would you find yourself in this situation? Thereâs a couple reasons:
- Bindgen forbids
&[T]unlessT: IntoWasmAbi Vec<&T>is not allowed- You just want the compiler to stop yelling
Types that have are IntoWasmAbi that are not Copy get cloned over the boundary (no handle) so they behave differently from both non-IntoWasmAbi and Copy types
Prefer Passing By Refence (by Default)
If you take one thing from this post, take this:
INFO
Never consume exported values across the boundary unless you have a clear reason to do so and are going to manage the handle on the JS side.
This one if pretty straightforward: pass everything around by reference. Consuming a value is totally âlegalâ to the compiler since it will happily free the memory on Rust side, but the JS-side handle will not get cleaned up. The next time you go to use that handle, it will throw an error. Unless youâre doing something specific with memory management, just outright avoid this situation: pass by &reference and use interior mutability.
This is a pretty easy pattern to follow: default to wrapping non-IntoWasmAbi types in Rc<RefCell<T>> or Arc<Mutex<T>>1 depending on if and how you have your code structured for async. The cost of going over the Wasm boundary definitely eclipses an Rc bump, so this is highly unlikely to be a performance bottleneck.
#[derive(Debug, Clone)]
#[wasm_bindgen(js_name = Foo)]
pub struct WasmFoo(pub(crate) Rc<RefCell<Foo>>)
#[derive(Debug, Clone)]
#[wasm_bindgen(js_name = Bar)]
pub struct WasmBar(pub(crate) Rc<RefCell<Bar>>)
#[wasm_bindgen(js_class = Foo)]
impl WasmFoo {
#[wasm_bindgen(js_name = "doSomething")]
pub fn do_something(&self, bar: WasmBar) -> Result<(), Error> {
// ...
}
}Avoid &mut
This one can be pretty frustrating when it happens: there are cases where taking &mut self can throw runtime errors due to re-entrancy. This pops up more frequently than I would have expected given that the default behaviour of JS is single threaded, but JSâs async doesnât have to respect Rustâs compile-time exclusivity2 checks.
INFO
If you canât prove exclusivity, donât pretend you have it. Use the relevant interiorâmutability primitive for your concurrency model.
Ducking Around Reference Restrictions
As mentioned earlier, you can use extern "C" JS-imports to model any duck typed interface, including Rust-exports. This means that we are able to work around several restrictions3 in wasm-bindgen.
Owned Collection Restriction
Bindgen restricts which types can be passed across the boundary. The one folks often run into first is that &[T] only works when T is IntoWasmAbi (including JS-imported types4) â i.e. usually not your Rust-exported structs. This means that you are often forced to construct a Vec<T>. This makes sense since JS is going to take control over the resulting JS array, and can mutate it as it pleases. It also means that when the type comes back in, you are unable to accept it as &[T] or Vec<T> unless the earlier IntoWasmAbi caveat applies.
A classic example of this is returning an owned Vec<T> of instead of a slice when T does not implement a JS-managed type. Whatâs returned to JS are not a bunch of Ts, but rather handles (e.g. { __wbg_ptr: 12345 }) to Ts that live on the Wasm side.4
On the other hand, weâre able to treat handles as duck typed objects that conform to some interface. Handles are far less restricted than Rust-exported types, and can be passed around more freely.
The workaround is fairly straightforward:
- Make your exported type cheap to clone
- Expose a namespaced clone method
- Import that method via a JS interface
- Convert with friendly ergonomics (
.into)
// Step 1: make it inexpensive to `clone` (i.e. using `Rc` or `Arc` if not already cheap)
#[derive(Debug, Clone)]
#[wasm_bindgen(js_name = Character)]
pub struct WasmCharacter(Rc<RefCell<Character>>)
#[wasm_bindgen(js_class = Character)]
impl WasmCharacter {
// ...
// Step 2: expose a *namespaced* (important!) `clone` function on the Wasm export
#[doc(hidden)]
pub fn __myapp_character_clone(&self) -> Self {
self.clone()
}
}
#[wasm_bindgen]
extern "C" {
type JsCharacter
// Step 3: create a JS-imported interface with that namespaced `clone`
pub fn __myapp_character_clone(this: &JsCharacter) -> WasmCharacter;
}
// Step 4: for convenience, wrap the namespaced clone in a `.from`
impl From<JsChcaracter> for WasmCharacter {
fn from(js: JsCharacter) -> Self {
js.__myapp_character_clone()
}
}
// Nicely typed Vec
// Step 5: use it! vvvvv
pub fn do_many_things(js_foos: Vec<JsFoo>) {
let rust_foos: Vec<WasmFoo> = js_foos.iter().map(Into::into).collect();
// ... ^^^^^^^
// Converted
}This still requires that you to manually track which parts bindgen thinks are JS-imports and which it thinks are Rust-exports, but with our naming convention itâs pretty clear whatâs happening. The conversion isnât free, but (IMO) it makes your interfaces significantly more flexible and legible.
Use wasm_refgen
The above pattern can be a bit brittle â even while writing the boilerplate â since all of the names have to line up just so, and you donât get compiler help when crossing the boundary like this. To help make this more solid, Iâve wrapped this pattern up as a macro exported from wasm_refgen.
use std::{rc::Rc, cell::RefCell};
use wasm_bindgen::prelude::*;
use wasm_refgen::wasm_refgen;
#[derive(Clone)]
#[wasm_bindgen(js_name = "Foo")]
pub struct WasmFoo {
map: Rc<RefCell<HashMap<String, u8>>>, // Cheap to clone
id: u32 // Cheap to clone
}
#[wasm_refgen(js_ref = JsFoo)]
#[wasm_bindgen(js_class = "Foo")]
impl WasmFoo {
// ... your normal methods
}Hereâs a diagram from the README about how it works:
âââââââââââââââââââââââââââââ
â â
â JS Foo instance â
â Class: Foo â
â Object { wbg_ptr: 12345 } â
â â
âââŹâââââââââââââââââââââââŹâââ
â â
â â
Implements â
â â
â â
âââââââââââââŒââââââââââââââââ â
â â â
â TS Interface: Foo â Pointer
â only method: â â
â __wasm_refgen_to_Foo â â
â â â
âââââââââââââŹââââââââââââââââ â
JS/TS â â
â â â â â â â â â â âââ â â â â â â â â â â ⌠â â â â â
Wasm â â
â â
âââââââââââââŒâââââââââââââââââââââââŒââââââââââââ
â ⌠⌠â
â ââââââââââââââââââ ââââââââââââââââââ â
â â â â â â
â â &JsFoo ââââââââââ¶ WasmFoo â â
â â Opaque Wrapper â â Instance #1 â â
â â â â â â
â ââââââââââââââââââ ââââââââââââââââââ â
ââââââââââââââââââââââââŹââââââââââââââââââââââââ
â
â
Into::into
(uses `__wasm_refgen_to_Foo`)
(which is a wrapper for `clone`)
â
â
âŒ
ââââââââââââââââââ
â â
â WasmFoo â
â Instance #2 â
â â
ââââââââââââââââââReferences passed over the boundary are already passed by ownership by bindgen â but these handles grab the reference off the boundary table. Recall that our Into::into calls clone under the hood, so these are always safe to consume without breaking the JS handle!
pub fn do_many_things(js_foos: Vec<JsFoo>) {
let rust_foos: Vec<WasmFoo> = js_foos.iter().map(Into::into).collect();
// ...
}Automatically Convert To JS Errors
There are a few ways to handle errors coming from Wasm, but IMO the best balance of detail and convenience is to turn them into js_sys::Errors on their way to JsValue. This lets us return Result<T, MyError> instead of Result<T, JsValue>.
For example, letâs say we have this type:
#[derive(Debug, Clone, thiserror::Error)]
pub enum RwError {
#[error("cannot read {0}")]
CannotRead(String),
#[error("cannot write")]
CannotWrite
}The fact that this is an enum is actually not a problem (the rest of the technique would work), but if youâre wrapping a different crate youâll need a newtype wrapper:
// Important: no #[wasm_bindgen]
#[derive(Debug, Clone, thiserror::Error)]
#[error(transparent)]
pub struct WasmRwError(#[from] RwError) // #[from] gets us `?` notation to lift into the newtypeWe âcouldâ slap a #[wasm_bindgen] on this and call it a day, but then we wouldnât get nice error info on the JS side. Instead, we convert to JsValue ourselves with this final bit of glue:
impl From<WasmRwError> for JsValue {
fn from(wasm: WasmRwError) -> Self {
let err = js_sys::Error::new(&wasm.to_string()); // Error message
err.set_name("RwError"); // Nice JS error type
err.into() // Convert to `JsValue`
}
}Now you can return Result<T, WasmRwError>, including if you want to call the Wasm-wrapped function elsewhere in your code. It retains the nice error on the Rust side (at minimum types-as-documentation). You also get ? notation without needing to do in-place JsValue conversion everywhere this error occurs; bindgen will helpfully do the conversion for you.
- Typed Rust errors
?propagation- Real JS
Errorobjects - Zero boilerplate at call sites
This works as a copy-paste template; Iâve considered wrapping it as a macro but itâs less than 10 LOC. I was actually surprised that something like #[wasm_bindgen(error)] wasnât available (maybe it is and I just canât find it; heck maybe itâs worth contributing upstream).
Print Build Info
This is a quality of life improvement that has saved me many hours of grief: print the exact build version, dirty status, and Git hash to the console on startup. If youâre working on your Wasm project at the same time as developing a pure-JS library that consumes it, getting a JS bundler like Vite to pick up changes can be flaky at best.
This takes a bit of setup, especially if youâre in a Cargo workspace, but pays off. Hereâs my current setup:
[workspace]
resolver = "3"
members = [
"build_info",
# ...
][package]
name = "build_info"
publish = false
# ...use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
#[allow(clippy::unwrap_used)]
fn main() {
let ws = env::var("CARGO_WORKSPACE_DIR").map_or_else(
|_| PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()),
PathBuf::from,
);
let repo_root = find_repo_root(&ws).unwrap_or(ws.clone());
let git_dir = repo_root.join(".git");
watch_git(&git_dir);
let git_hash = cmd_out(
"git",
&[
"-C",
#[allow(clippy::unwrap_used)]
repo_root.to_str().unwrap(),
"rev-parse",
"--short",
"HEAD",
],
)
.unwrap_or_else(|| "unknown".to_string());
let dirty = cmd_out(
"git",
&["-C", repo_root.to_str().unwrap(), "status", "--porcelain"],
)
.is_some_and(|s| !s.is_empty());
let git_hash = if dirty {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
format!("{git_hash}-dirty-{secs}")
} else {
git_hash
};
println!("cargo:rustc-env=GIT_HASH={git_hash}");
}
fn cmd_out(cmd: &str, args: &[&str]) -> Option<String> {
Command::new(cmd).args(args).output().ok().and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut cur = Some(start);
while let Some(dir) = cur {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
cur = dir.parent();
}
None
}
fn watch_git(git_dir: &Path) {
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
if let Ok(head) = fs::read_to_string(git_dir.join("HEAD")) {
if let Some(rest) = head.strip_prefix("ref: ").map(str::trim) {
println!("cargo:rerun-if-changed={}", git_dir.join(rest).display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
}
}
println!("cargo:rerun-if-changed={}", git_dir.join("index").display());
let fetch_head = git_dir.join("FETCH_HEAD");
if fetch_head.exists() {
println!("cargo:rerun-if-changed={}", fetch_head.display());
}
}#![no_std]
pub const GIT_HASH: &str = env!("GIT_HASH");âŠand finally where to get it to print in Wasm:
use wasm_bindgen::prelude::*;
// ...
#[wasm_bindgen(start)]
pub fn start() {
set_panic_hook();
// I actually use `tracing::info!` here,
// but that's out of scope for this article
web_sys::console.info1(format!(
"ïžyour_package_wasm v{} ({})",
env!("CARGO_PKG_VERSION"),
build_info::GIT_HASH
));
}Wrap Up
Rust+Wasm is powerfulâbut unforgiving if you pretend the boundary isnât there. Be explicit, name things clearly, pass by reference, and duck typing around any (unreasonable) limitations bindgen places on you.
With any luck, thatâs helpful to others! I may update this over time as I find myself using more patterns.
Footnotes
-
FWIW I prefer
futures::lock::Mutexonstd, orasync_lock::Mutexunderno_std. â© â©2 -
Iâm actually more annoyed by Rust on this one (see earlier comment about naming being important).
&mutisnât âjustâ a mutable reference; itâs an exclusive reference, which happens to make direct mutability viable, but the semantics are more about exclusivity. â© -
I sometimes wonder if itâs my lot in life to import features between langauges. Witchcraft was my first real library of note, and I canât seem to stop abusing languages this way đ â©
-
This simple example is already more nuance than would be ideal to juggle when writing code. â© â©2