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

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

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.

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