Calling Web APIs from Rust with wasm-bindgen

This guide shows how to call browser APIs — grab window and document, create and append DOM nodes, log to the console, and fetch a URL — directly from Rust, using the web-sys and js-sys crates that sit on top of wasm-bindgen.

web-sys is a generated binding for the entire Web IDL surface; js-sys binds the JavaScript built-ins (Array, Promise, Reflect, and so on). Both are gated behind a long list of Cargo features so you only compile the bindings you actually use. Getting those features right is most of the work.

Mechanically, none of this is new machinery. Every web-sys type — Window, Document, Element — is a thin newtype around JsValue, the same opaque table-index handle the generated glue uses everywhere. A DOM “object” in Rust is an integer pointing at a real JavaScript node; calling a method on it is an extern import that trampolines back into JavaScript. The Cargo features simply decide which of those imports get compiled in. That is why a missing feature shows up as “no method named”, not as a runtime error — the binding was never generated.

Prerequisites

  • [ ] Rust 1.78+ with the wasm32-unknown-unknown target
  • [ ] wasm-pack 0.12+ and a version-matched wasm-bindgen-cli
  • [ ] An HTML page that loads the generated ES module and runs in a real browser (these APIs do not exist in Node)
  • [ ] Familiarity with the import direction covered in the wasm-bindgen deep dive

Two facts explain nearly everything about this workflow. First, web-sys is generated from the same WebIDL that defines the browser APIs, so the Rust names track the JavaScript ones mechanically — getElementById becomes get_element_by_id, and a nullable return becomes an Option. Second, every one of those calls is a boundary crossing into JavaScript, because the DOM lives there and always will. The ergonomics are excellent; the cost model is unchanged.

That combination shapes how you should use it. Reaching for the DOM once during setup is free in practice. Reaching for it inside a loop puts a crossing in your inner loop no matter how idiomatic the Rust looks. Where a function needs both computation and DOM access, keep the computation in Rust and hand the results back in one batch, exactly as you would from any other Wasm module.

Idiomatic Rust, unchanged cost model The web-sys crate is generated from the browser's WebIDL, so Rust method names follow the JavaScript ones and nullability becomes Option. Each call still goes out through the import table into JavaScript, where the actual API lives. WebIDL the browser's own definitions document.getElementById(id) nullable return generated web-sys document.get_element_by_id(id) → Option<Element> names and nullability track the IDL at run time an imported function call out to JavaScript, where the DOM is one crossing per call, always Enable web-sys features one by one: each feature you turn on generates bindings that land in the glue whether you call them or not. And keep DOM work at the edges of the module — the ergonomics tempt you to put it in the middle.

Procedure

  1. Enable the web-sys features you need. Each interface is its own feature; miss one and the type simply does not exist. In Cargo.toml:

    [dependencies]
    wasm-bindgen = "0.2"
    js-sys = "0.3"
    wasm-bindgen-futures = "0.4"
    
    [dependencies.web-sys]
    version = "0.3"
    features = [
      "Window", "Document", "Element", "HtmlElement", "Node", "Text",
      "console", "Request", "RequestInit", "Response", "Headers",
    ]
  2. Reach the global objects. window() returns an Option<Window>; from it you get the Document.

    use wasm_bindgen::prelude::*;
    use web_sys::{window, Document};
    
    fn document() -> Document {
        window().expect("no global window").document().expect("no document")
    }
  3. Manipulate the DOM. Create an element, set its text, and append it to the body. Each call here crosses the boundary into JavaScript.

    #[wasm_bindgen]
    pub fn render_message(text: &str) -> Result<(), JsValue> {
        let doc = document();
        let p = doc.create_element("p")?;
        p.set_text_content(Some(text));
        doc.body().expect("no body").append_child(&p)?;
        Ok(())
    }
  4. Log to the console via web_sys::console, which takes JsValue arguments.

    web_sys::console::log_1(&JsValue::from_str("rendered from Rust"));
  5. Call fetch and await it. Browser fetch returns a Promise; bridge it to a Rust Future with wasm_bindgen_futures::JsFuture, and export an async function — wasm-bindgen turns it into a Promise-returning JavaScript function.

    use wasm_bindgen_futures::JsFuture;
    use web_sys::{Request, RequestInit, Response};
    
    #[wasm_bindgen]
    pub async fn fetch_status(url: &str) -> Result<u16, JsValue> {
        let opts = RequestInit::new();
        opts.set_method("GET");
        let request = Request::new_with_str_and_init(url, &opts)?;
    
        let win = window().unwrap();
        let resp_value = JsFuture::from(win.fetch_with_request(&request)).await?;
        let resp: Response = resp_value.dyn_into()?;
        Ok(resp.status())
    }
  6. Build and wire it into a page.

    wasm-pack build --target web --out-dir pkg
    import init, { render_message, fetch_status } from "./pkg/my_crate.js";
    
    await init();
    render_message("hello from Rust");
    console.log("status:", await fetch_status("/api/ping"));

Expected output

A <p>hello from Rust</p> appended to the page body, plus console output:

rendered from Rust
status: 200
Features are not free The web-sys crate is enormous and gated behind Cargo features. Enabling only the interfaces you call keeps the generated bindings small; enabling a broad set, or all of them, inflates both compile time and output size for APIs you never touch. Window, Document, Element what a typical page actually needs + CanvasRenderingContext2d added deliberately, for one module + a broad feature set compile time and glue size grow for interfaces the module never calls Add features one at a time in response to a compile error, and remove them when the code that needed them goes away. This is one of the few size levers that costs nothing to pull.

Gotchas

**no method named \create_element` found for struct `Document`** — the Documentfeature is enabled but the method's interface is not. DOM creation lives behind theDocument*and*Elementfeatures; missing-method errors almost always mean a feature flag is absent. Add the interface named in the error to theweb-sys features` list and rebuild.

**the trait bound \Request: …` is not satisfied/cannot find type `Request` in module** — the Request, RequestInit, or Responsefeature is not enabled. Eachfetchtype is a separate feature; enable all three plusWindow(forfetch_with_request`).

Promise panics with not yet ready or the future never resolves — you called .await without wasm-bindgen-futures driving the executor, or forgot to mark the export async. The exported function must be async so wasm-bindgen returns a JavaScript Promise and runs the future on the microtask queue.

JsValue(TypeError: Failed to fetch) — a network or CORS failure, surfaced as a thrown JsValue. Because fetch rejects rather than panics, propagate it with ? and inspect the message on the JavaScript side; do not unwrap() network calls.

Performance note

Every web-sys method is a boundary crossing into JavaScript, and DOM calls in particular are not free — each create_element, set_text_content, and append_child is a separate call plus the engine’s own DOM work. Building 1,000 nodes one method at a time can cost several milliseconds and thrash layout. The fix is the same as in JavaScript: batch. Build a DocumentFragment (enable its feature), append children to it in a loop, and insert the fragment once — turning N layout-touching calls into one. Reaching across the boundary per node is what makes naive Rust DOM code slower than hand-written JavaScript, not the Wasm execution itself.

The corollary is a clear design rule: keep the chatty, per-node work on the JavaScript side and reserve Rust for the compute-heavy part. If you are generating a large table, compute the cell values in Rust, return them as one flat array or a single string of HTML, and let a thin JavaScript layer do the DOM insertion in one shot. Each await-ed fetch is also a boundary round trip plus a microtask hop, so batching applies to network calls too — fire requests concurrently with js_sys::Promise::all rather than awaiting them one at a time in a loop.

The listener lifetime problem A Rust closure passed to addEventListener is dropped at the end of the function unless it is kept alive. Dropping it invalidates the function-table entry, so the next event calls into a released slot and the browser reports a null function or signature mismatch. closure dropped at end of scope registered, then released immediately the listener still points at the slot next click → null function or signature mismatch closure kept alive deliberately stored in a struct, or forget() at the top level released when the listener is removed correct — and forget() is a deliberate leak, not a bug Prefer storing the Closure where its lifetime is visible; reach for forget() only for listeners that genuinely live as long as the page.

Error handling across the call

Web APIs fail in ways Rust has to model explicitly. A fallible call returns Result<T, JsValue>, and the error value is whatever JavaScript threw — a DOMException, a TypeError, or an arbitrary object. That means ? works as usual inside a function returning Result<_, JsValue>, and an error propagated all the way out of an exported function becomes a rejected promise or a thrown exception on the JavaScript side, which is normally what you want.

The part worth handling deliberately is the middle ground: an error you can recover from. Matching on a JsValue is awkward because it is untyped, so the usual approach is to convert early — check for the specific DOMException name you care about, translate it into a Rust error type, and let everything else propagate untouched. That keeps the recoverable cases legible and avoids a catch-everything block that silently swallows a genuine bug.

Two smaller points save time. console_error_panic_hook turns a Rust panic into a readable console message with a stack trace instead of the default unreachable executed, and installing it in your initialiser costs a few hundred bytes in debug builds. And an Option returned by a web-sys getter means the element or property was genuinely absent — treat it as a real branch rather than unwrapping, because a missing element on a page you do not control is a normal condition, not a bug.

Frequently Asked Questions

Why is the web-sys feature list so long — can’t I enable everything? You can, but compile time and binary size both blow up because every enabled interface generates binding glue. The features exist precisely so you pay only for the interfaces you call. Add them as the compiler reports missing methods or types.

Can I call these APIs from a Web Worker? Some, not all. A worker has no window or document, so DOM calls fail there. fetch, console, and WorkerGlobalScope APIs do work — gate worker code on the WorkerGlobalScope feature and use web_sys::WorkerGlobalScope instead of Window.

How do I read the fetched response body, not just the status? Call resp.text() or resp.array_buffer(), both of which return a Promise; wrap each in JsFuture and .await it, then convert the resolved JsValue (as_string() for text, or a Uint8Array view for the buffer).

← Back to wasm-bindgen Deep Dive