When println! isn't enough
You've written a Rust function that calculates the next Fibonacci number. You compile it to WebAssembly. You load it in the browser. You call the function. The console shows the result. The page remains stubbornly empty. You need to update the UI. In JavaScript, you'd grab an element and set its text. In Rust, you can't just reach into the browser's internals and mutate memory. The browser speaks JavaScript. Rust speaks Rust. You need a bridge that respects both worlds. That bridge is web-sys.
The bridge between Rust and the browser
web-sys is a crate that gives you safe Rust types for browser objects like Document, Element, and Window. It doesn't let you touch raw pointers or guess memory layouts. It wraps the browser's JavaScript APIs in Rust structs and methods. Under the hood, wasm-bindgen generates the glue code that translates Rust calls into JavaScript function calls at runtime.
Think of web-sys as a generated remote control. You don't need to know how the TV's circuit board works. You just press the buttons defined on the remote. The remote is built from the browser's official TypeScript definitions, so it stays up to date as browsers evolve. If the browser adds a new API, the web-sys crate gets an update, and you get the new button on your remote.
You don't need to memorize the DOM API. You just need to know how to ask web-sys for the right features and handle the results it returns.
Minimal DOM manipulation
Start with a simple example that creates a div and appends it to the body. This shows the core pattern: get the window, get the document, create an element, mutate it, attach it.
use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlElement, Window};
#[wasm_bindgen(start)]
// This attribute tells wasm-pack to call main() automatically when the module loads.
pub fn main() {
// Get the global window object. unwrap() is safe here because browsers always have a window.
let window = web_sys::window().expect("no global window");
let document = window.document().expect("no document on window");
let body = document.body().expect("no body on document");
// Create a div element. create_element returns a generic Element, so we cast it.
let div = document.create_element("div").expect("failed to create element");
let div = div.dyn_into::<HtmlElement>().expect("not an HtmlElement");
// Set text content. Some("") clears text; None leaves it alone.
div.set_text_content(Some("Hello from Rust!"));
// Append to the body.
body.append_child(&div).expect("failed to append child");
}
Add the dependencies to Cargo.toml. Notice the features list.
[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Document", "HtmlElement", "Window"] }
Compile with wasm-pack:
wasm-pack build --target web
Include the generated JavaScript glue in your HTML to load the module. The pkg/ directory contains the .wasm file and the .js glue code.
Convention aside: web-sys contains bindings for thousands of browser APIs. If Rust compiled all of them by default, your binary would be massive and compile times would suffer. You opt in to only the APIs you use. This is a hard rule in the ecosystem. If you forget a feature, the compiler complains about missing methods. Check the web-sys docs for the exact feature names; they usually match the type names. Enable the feature, and the methods appear.
How the glue works
When you run wasm-pack build, wasm-bindgen scans your code. It sees calls to web_sys methods. It generates a JavaScript file that implements those methods by calling the actual browser APIs. It also generates the Rust code that knows how to pass arguments across the WebAssembly boundary.
At runtime, your Rust code calls a function like set_text_content. That call jumps into the generated JavaScript glue. The glue takes the string from Rust memory, converts it to a JavaScript string, calls element.textContent = ..., and returns. The browser updates the DOM. You never touch JavaScript directly, but JavaScript is doing the work.
The conversion is automatic for common types. &str becomes a JavaScript string. u32 becomes a number. Option<T> becomes null or the value. Complex types like Element are passed as references to the underlying JavaScript object. The compiler handles the marshaling. You focus on the logic.
Trust the generated bindings. They keep you safe from the browser's chaos.
Real-world pattern: Event handlers
Static DOM updates are easy. Interactive UIs require event handlers. You need to register a Rust function to run when the user clicks a button. This introduces closures and a critical memory pattern.
use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlElement, Window};
#[wasm_bindgen]
// Expose this function to JavaScript so we can use it as an event handler.
pub fn on_click(event: web_sys::MouseEvent) {
let target = event.target().expect("no target");
let button = target.dyn_into::<HtmlElement>().expect("not an HtmlElement");
button.set_text_content(Some("Clicked!"));
}
#[wasm_bindgen(start)]
pub fn main() {
let window = web_sys::window().expect("no global window");
let document = window.document().expect("no document on window");
let body = document.body().expect("no body on document");
let button = document.create_element("button").expect("failed");
let button = button.dyn_into::<HtmlElement>().expect("not HtmlElement");
button.set_text_content(Some("Click me"));
// Wrap the Rust function in a Closure so JavaScript can call it.
let closure = Closure::wrap(Box::new(on_click) as Box<dyn FnMut(_)>);
// Add the event listener. unchecked_ref() is safe here because we hold the closure.
button.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).expect("failed");
// Prevent Rust from dropping the closure while JS holds a reference.
closure.forget();
body.append_child(&button).expect("failed");
}
Convention aside: The closure.forget() call looks scary. It intentionally leaks memory. This is a standard pattern in Rust WASM. You create a Closure to wrap a Rust function so JavaScript can call it. If you let the Closure go out of scope, Rust drops it and frees the memory. JavaScript still holds a reference to that memory. The next click crashes the browser. Calling forget tells Rust to stop tracking the closure. The memory stays alive as long as the browser needs it. When the page unloads, the browser reclaims everything. You can also manually drop the closure later if you remove the event listener.
Leak the closure intentionally. The browser will clean up when the page dies.
Pitfalls and compiler errors
web-sys is safe, but it exposes browser APIs that can fail. You'll encounter a few common traps.
dyn_into is a checked cast. create_element returns a generic Element. You need HtmlElement to call set_text_content. If you cast wrong, the cast fails at runtime and panics. Always check the element type before casting, or use dyn_into inside a match to handle the error gracefully.
let element = document.create_element("div").expect("failed");
let div = match element.dyn_into::<HtmlElement>() {
Ok(div) => div,
Err(_) => panic!("Created element is not an HtmlElement"),
};
If you try to pass a Rust String directly to a method expecting a JavaScript string, the compiler rejects you with E0277 (trait bound not satisfied). wasm-bindgen handles the conversion automatically for &str, but not for owned String in all contexts. Use &str or JsValue when the API demands it.
web-sys methods return Result<T, JsValue>. You must handle errors. expect is fine for simple apps, but production code should handle JsValue errors. The browser might reject an operation due to security policies, missing permissions, or invalid arguments. Ignoring the result hides bugs.
Treat every Result from web-sys as a promise the browser might break. Handle it.
Memory and performance
Every call to web-sys crosses the WebAssembly boundary. The compiler generates code to serialize arguments, jump to JavaScript, and deserialize results. This is fast, but not free. If you update the DOM in a loop, you pay the boundary cost for every iteration.
Batch your changes. Build a string in Rust, then set the text once. Or use DocumentFragment to append multiple nodes at once. The browser reflows the layout once instead of once per node.
use web_sys::DocumentFragment;
// Create a fragment to batch DOM updates.
let fragment = document.create_document_fragment();
for i in 0..100 {
let item = document.create_element("li").expect("failed");
let item = item.dyn_into::<HtmlElement>().expect("not HtmlElement");
item.set_text_content(Some(&format!("Item {}", i)));
fragment.append_child(&item).expect("failed");
}
// Append the fragment once. The browser updates the DOM in one batch.
body.append_child(&fragment).expect("failed");
Also, add console_error_panic_hook to your project. Without it, Rust panics are silent in the browser. The hook prints panic messages to the browser console, making debugging possible.
// In main, before anything else:
console_error_panic_hook::set_once();
Batch the updates. The browser will thank you with smoother rendering.
Decision matrix
Rust offers several ways to interact with the web. Pick the right tool for the job.
Use web-sys when you need direct access to browser APIs and want fine-grained control over the generated bindings. Use gloo when you want a more ergonomic, Rust-idiomatic wrapper around common web tasks like HTTP requests, storage, and events without writing boilerplate. Use a framework like Yew or Leptos when you are building a complex UI with state management, component hierarchies, and reactive updates. Use js-sys when you need to call arbitrary JavaScript functions that web-sys doesn't cover, or when you are writing the glue code for a custom library.
Start with web-sys to learn the DOM. Move to a framework when the boilerplate outweighs the logic.