The Bridge Between Worlds
You've compiled your Rust code to WebAssembly. The math is blazing fast. The logic is solid. Now you need to update the DOM, read a user's timezone, or trigger a browser alert. Rust running in WASM lives in a sandbox. It has no idea what a document object is. It can't reach out and touch the browser. You need a bridge.
The bridge is wasm-bindgen. It generates the glue code that lets your Rust functions call JavaScript and vice versa. You write Rust that looks like it's calling JavaScript functions. wasm-bindgen scans your code, generates the necessary JavaScript wrapper, and handles the type conversions, memory management, and calling convention differences automatically. You don't write the glue. The tool writes it for you.
Think of your WASM module as a specialized engine. JavaScript is the car body. wasm-bindgen is the wiring harness. You don't solder the wires by hand. You plug into the connectors the tool provides. Trust the glue. wasm-bindgen handles the messy interop so you can focus on logic.
How the Glue Works
When you mark a function or type with #[wasm_bindgen], the build process does two things. It compiles your Rust to WASM, and it generates a JavaScript file that acts as the interface.
For Rust calling JavaScript, you declare external functions using extern "C" blocks annotated with #[wasm_bindgen]. The tool generates JavaScript functions that Rust can import. When your WASM code calls one of these functions, it jumps into the JavaScript runtime. The generated code handles converting Rust types to JavaScript types, invoking the JS function, and converting the result back.
Type safety crosses the boundary. If you declare a function that takes a string, wasm-bindgen ensures a string is passed. If you return a Result, the tool converts errors into JavaScript exceptions. The compiler checks your Rust side. The generated JavaScript checks the runtime side. You get safety without writing boilerplate.
Convention aside: wasm-bindgen generates a JavaScript file and a WASM file. The JavaScript file is the entry point. You import the JS file, not the WASM file. The JS file loads the WASM and sets up the imports. Always import the .js file in your frontend code.
Minimal Example: Calling console.log
The simplest way to call JavaScript is to declare a function that exists in the global scope or a namespace. Here's how you call console.log from Rust.
use wasm_bindgen::prelude::*;
/// Declare the JavaScript console.log function.
#[wasm_bindgen]
extern "C" {
// Map to the `console` namespace in JavaScript.
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
/// A Rust function that calls JavaScript.
#[wasm_bindgen]
pub fn rust_calls_js() {
// Invoke the JS function.
log("Hello from Rust!");
}
The extern "C" block tells Rust that these functions are defined outside the crate. The #[wasm_bindgen] attribute tells the tool to generate the bindings. The js_namespace attribute specifies that log lives inside the console object in JavaScript.
When you call rust_calls_js, the WASM code invokes the log function. The generated JavaScript wrapper receives the call, converts the Rust string to a JavaScript string, and calls console.log. The output appears in the browser console.
You can also call functions that return values. The return type must be something wasm-bindgen can convert. Primitives, strings, and JsValue work. Complex Rust structs do not, unless you export them explicitly.
The attribute does the heavy lifting. You write Rust; the tool writes the bridge.
Realistic Example: DOM Updates with web_sys
Calling console.log is useful for debugging. Real applications need to manipulate the DOM. The web_sys crate provides bindings for the entire Web API. It's generated from the TypeScript definitions, so it stays up to date with the web standards.
Here's how you update the text of an element by ID.
use wasm_bindgen::prelude::*;
use web_sys::{window, Document, Element};
/// Update the text content of an element by ID.
///
/// Returns an error if the element is not found or the window is unavailable.
#[wasm_bindgen]
pub fn update_status(element_id: &str, new_text: &str) -> Result<(), JsValue> {
// Get the window object.
let window = window().ok_or("No window")?;
// Get the document from the window.
let document = window.document().ok_or("No document")?;
// Find the element by ID.
let element = document.get_element_by_id(element_id).ok_or("Element not found")?;
// Set the text content.
element.set_text_content(Some(new_text));
Ok(())
}
This function uses web_sys to access window and document. It chains calls to find an element and update its text. The return type is Result<(), JsValue>. This is the standard pattern for functions that might fail due to JavaScript errors. When the Rust function returns an Err, wasm-bindgen throws a JavaScript exception. JavaScript code calling this function can catch the error.
Convention aside: web_sys requires feature flags in Cargo.toml. You must enable the features for the APIs you use. This keeps the binary size small. Add features = ["Window", "Document", "Element"] to the web_sys dependency. The tool won't compile if you use a type without enabling its feature. This is a safety net against accidental bloat.
You can also export Rust structs to JavaScript. This lets you maintain state in Rust and expose methods.
#[wasm_bindgen]
pub struct Counter {
count: u32,
}
#[wasm_bindgen]
impl Counter {
/// Create a new counter.
#[wasm_bindgen(constructor)]
pub fn new() -> Counter {
Counter { count: 0 }
}
/// Increment the counter and return the new value.
pub fn increment(&mut self) -> u32 {
self.count += 1;
self.count
}
}
The #[wasm_bindgen(constructor)] attribute marks the constructor. JavaScript can instantiate this struct with new Counter(). The increment method is exported as a method on the prototype. JavaScript calls counter.increment(). The tool handles passing the self reference across the boundary.
Return Result<(), JsValue> and let errors cross the boundary as exceptions. JavaScript expects throws, not Options.
Pitfalls and Compiler Errors
Calling JavaScript from Rust introduces a few traps. The compiler catches most of them, but understanding the failures saves time.
If you try to return a type that wasm-bindgen doesn't know, the build fails. You'll see an error like "the return type of this function cannot be converted to JavaScript". Stick to primitives, strings, vectors of primitives, JsValue, and Result<T, JsValue>. If you need complex types, wrap them in a struct and export the struct.
Mutable references can be tricky. JavaScript doesn't have lifetimes. When you pass a &mut reference to JavaScript, the tool has to ensure the reference stays valid. wasm-bindgen handles this by freezing the object or using a proxy, but it's often safer to return values instead of mutating in place. If you need mutation, consider returning the new value or using a struct with methods.
Memory leaks happen when you hold JsValue references in Rust. A JsValue holds a reference to a JavaScript object. Dropping the JsValue decrements the JavaScript reference count. If you store JsValue in a global variable or a long-lived struct, you might prevent the JavaScript object from being garbage collected. Drop JsValue as soon as you're done with it.
The web_sys crate is huge. If you forget to enable features, you get E0432 (use of undeclared type) or similar errors. Check Cargo.toml. The feature names match the type names. Enable Window to use window(). Enable Element to use Element.
If you try to call a JavaScript function that doesn't exist, the error happens at runtime. wasm-bindgen can't check the JavaScript side at compile time. You'll get a runtime error like "TypeError: function is not defined". Double-check the function name and namespace. Use js_name to rename if needed.
Convention aside: Use #[wasm_bindgen(js_name = "...")] to rename exported functions or match JavaScript naming conventions. Rust uses snake_case. JavaScript uses camelCase. The attribute lets you keep Rust style in code and JS style in the interface.
If the compiler complains about conversion, check the type. wasm-bindgen is strict for a reason: type safety stops runtime crashes.
Decision Matrix
Pick the right tool for the job. The ecosystem offers several ways to interact with JavaScript.
Use #[wasm_bindgen] on extern "C" blocks when you need to call specific JavaScript functions that aren't wrapped by a crate.
Use web_sys when you need to interact with the DOM, fetch APIs, canvas elements, or other Web APIs from Rust.
Use js_sys when you need to work with JavaScript primitives, arrays, maps, or global objects like Math or Date.
Use gloo when you want a higher-level, idiomatic Rust API for common web tasks like timers, storage, routing, or file handling.
Use wasm-bindgen-futures when your Rust function needs to await a JavaScript promise or return a promise to JavaScript.
Use JsValue as an escape hatch when you need to pass arbitrary JavaScript values across the boundary without type information.
Keep the boundary clean. Expose what JavaScript needs, hide what it doesn't.