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-unknowntarget - [ ]
wasm-pack0.12+ and a version-matchedwasm-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
-
Enable the
web-sysfeatures you need. Each interface is its own feature; miss one and the type simply does not exist. InCargo.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", ] -
Reach the global objects.
window()returns anOption<Window>; from it you get theDocument.use wasm_bindgen::prelude::*; use web_sys::{window, Document}; fn document() -> Document { window().expect("no global window").document().expect("no document") } -
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(()) } -
Log to the console via
web_sys::console, which takesJsValuearguments.web_sys::console::log_1(&JsValue::from_str("rendered from Rust")); -
Call
fetchand await it. Browserfetchreturns aPromise; bridge it to a RustFuturewithwasm_bindgen_futures::JsFuture, and export anasyncfunction —wasm-bindgenturns it into aPromise-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()) } -
Build and wire it into a page.
wasm-pack build --target web --out-dir pkgimport 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).
Related
- wasm-bindgen Deep Dive — how
externimports andJsValuewrappers likeWindowwork. - Passing JS objects to Rust with wasm-bindgen — the inbound-data counterpart to these outbound calls.
- Is WebAssembly faster than JavaScript for DOM manipulation? — why per-node boundary crossings dominate.
- ESM bindings & module generation — packaging the generated module for the browser.
← Back to wasm-bindgen Deep Dive