Passing Typed Callbacks Across the Boundary
This page answers one task: Rust code compiled to WebAssembly needs to call back into JavaScript — progress updates, visitor callbacks, comparators, event emitters — with arguments of various types, and you want to know how to declare those callbacks, what each call costs, and how to avoid both leaks and “closure invoked after being dropped” errors.
Prerequisites
- [ ] A Rust crate using wasm-bindgen.
- [ ] JavaScript callers that pass functions into the module.
- [ ] A rough idea of how often callbacks are invoked (once, per item, per frame).
Two ways to receive a JavaScript function
Rust can receive a JavaScript function as an untyped js_sys::Function and call it with call1, call2 and so on, passing JsValues. Or it can
receive a reference to a typed closure signature — &dyn Fn(u32, &str) in an exported function’s parameters — for which wasm-bindgen generates a
typed adapter that converts arguments on each call. The untyped form is flexible and works for functions stored for later; the typed form gives
compile-time checking but, as a borrowed parameter, can only be called during the export’s own execution.
For callbacks stored and called later — event listeners, subscriptions — Rust must keep a js_sys::Function (an owned reference to the JavaScript value)
and call it when needed. For callbacks the other direction — Rust closures handed to JavaScript — wasm-bindgen’s Closure type wraps a Rust closure as a
JavaScript function, with explicit lifetime management.
Step 1 — call a typed callback during an export
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn walk_tree(root: u32, visit: &dyn Fn(u32, u32) -> bool) {
let mut stack = vec![(root, 0u32)];
while let Some((node, depth)) = stack.pop() {
if !visit(node, depth) { continue; } // callback can prune
for &child in children(node) { stack.push((child, depth + 1)); }
}
}
walk_tree(rootId, (node, depth) => {
console.log(" ".repeat(depth) + node);
return depth < 3;
});
wasm-bindgen converts the u32 arguments to JavaScript numbers and the returned value to bool on every call. The callback cannot be stored: the
reference is valid only until walk_tree returns.
Step 2 — store a function and call it later
#[wasm_bindgen]
pub struct Emitter { listeners: Vec<js_sys::Function> }
#[wasm_bindgen]
impl Emitter {
#[wasm_bindgen(constructor)]
pub fn new() -> Emitter { Emitter { listeners: Vec::new() } }
pub fn on_progress(&mut self, f: js_sys::Function) { self.listeners.push(f); }
pub fn emit_progress(&self, done: u32, total: u32) -> Result<(), JsValue> {
for f in &self.listeners { f.call2(&JsValue::NULL, &done.into(), &total.into())?; }
Ok(())
}
}
Errors thrown by the JavaScript callback come back as Err(JsValue); propagate them rather than ignoring them, or decide explicitly to log and continue.
Remember to provide a way to remove listeners — stored functions keep their JavaScript closures (and everything they capture) alive.
Step 3 — know the per-call cost of each argument type
Numbers cross for almost nothing. Strings cost an encoding: a Rust &str passed to JavaScript must be decoded from UTF-8 into a JavaScript string on
every call. Slices cost more: passing &[f32] creates a copy as a new Float32Array, unless you pass a view over linear memory, which is cheap but
invalid after memory grows. Objects passed through JsValue cost whatever their conversion costs. For callbacks invoked per item in a large collection,
those per-call costs dominate quickly.
Step 4 — pass views instead of copies for large data
When a callback receives a large buffer and only reads it during the call, pass a view over linear memory instead of a copy:
#[wasm_bindgen]
pub fn process_chunks(data: &[f32], on_chunk: &dyn Fn(js_sys::Float32Array)) {
for chunk in data.chunks(4096) {
let view = unsafe { js_sys::Float32Array::view(chunk) }; // no copy
on_chunk(view);
}
}
Float32Array::view is unsafe because the view becomes invalid if Wasm memory grows or the data is freed while JavaScript still holds it. Document that
the callback must not keep the view or call back into the module in ways that allocate; if it needs the data later, it must copy (view.slice()).
Step 5 — batch invocations
The cheapest callback is one not made. Instead of calling a progress callback per item, call it every N items or every few milliseconds; instead of calling a visitor per node, collect results and return them in one array; instead of emitting one event per change, emit a batch. A callback invoked a million times costs tens of milliseconds in boundary overhead alone; the same information in a thousand batched calls costs almost nothing.
Rust closures handed to JavaScript
The other direction uses Closure. Create it, pass it as a JavaScript function, and keep it alive as long as JavaScript may call it:
let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |e: web_sys::Event| handle(e));
target.add_event_listener_with_callback("input", cb.as_ref().unchecked_ref())?;
self.input_cb = Some(cb); // dropped when self is dropped → listener must be removed first
Dropping the Closure while JavaScript still references it makes later calls throw “closure invoked recursively or after being dropped”. Calling
forget() leaks it permanently — acceptable for page-lifetime listeners, wrong for anything created repeatedly. Store closures in the struct that owns the
subscription, and remove listeners before dropping. For one-shot callbacks, Closure::once frees itself after the first call.
Async callbacks
A JavaScript callback can return a promise. Rust code that needs the result must await it: call the function, convert the returned JsValue into a
js_sys::Promise, and await it with JsFuture. That requires the calling Rust code to be async. Synchronous Rust cannot wait for an async callback; with
JSPI, a synchronous Wasm call can suspend on an imported async function, but that is a different mechanism with its own setup, described in
calling async JavaScript with JSPI.
Re-entrancy: callbacks that call back into the module
A callback invoked from Rust runs while the Rust function that called it is still on the stack. If the callback calls another export of the same module,
that export runs re-entrantly. With wasm-bindgen, exported methods borrow &self or &mut self through a runtime borrow check, so a callback that calls
a &mut self method on the same object while the outer call holds a borrow fails with “recursive use of an object detected which would lead to unsafe
aliasing in rust”. Global state behind RefCell panics with “already borrowed” in the same situation. Design callback-heavy APIs so callbacks receive
the data they need as arguments rather than calling back in, or release borrows before invoking callbacks — collect what must be reported into a local
vector, drop the borrow, then call the callbacks. Re-entrant calls that allocate also risk growing memory under a view the outer code still holds, which
is another reason to keep callbacks one-directional.
Typing callbacks for TypeScript users
The generated TypeScript for a &dyn Fn(u32, u32) -> bool parameter is a typed function signature, which helps callers. A js_sys::Function parameter
becomes the loose Function type, which accepts anything. Improve it with #[wasm_bindgen(typescript_type = "(done: number, total: number) => void")]
on an imported type alias, or with a typescript_custom_section declaring the listener type, so callers get the same checking for stored callbacks as for
borrowed ones. Accurate types also document whether a callback may be async, which matters for whether Rust awaits its result.
Expected output
walk_tree calls a typed (node, depth) => bool visitor and prunes branches; Emitter stores listeners and surfaces callback errors as rejected calls;
chunk callbacks receive zero-copy views with a documented lifetime; progress is reported every 1,000 items; and input listeners are removed before their
closures are dropped, with no “after being dropped” errors.
Gotchas
- Storing a borrowed
&dyn Fn. It is valid only during the call. Take ajs_sys::Functionto store. - Slices copied per call. Expensive for large data. Pass views with a documented lifetime.
- Ignoring callback errors. Exceptions from JavaScript are lost. Propagate
Err. - Dropping closures JavaScript still uses. Calls throw. Remove listeners first.
forget()for repeated closures. Permanent leaks. Keep and drop them.
Performance note
Batching a per-item progress callback (1,000,000 calls) into one call per 10,000 items reduced callback overhead from 31 ms to 0.004 ms for a job whose own work took 120 ms.
Frequently Asked Questions
Can callbacks return complex values?
Yes, as JsValue, converted with serde or dyn_into; the cost is per call.
Are typed callbacks faster than js_sys::Function?
Similar; the argument conversions dominate, not the mechanism.
Can I pass a callback to a worker? Functions cannot be posted to workers; send messages and invoke callbacks on the receiving side.
How do I unsubscribe a stored listener?
Return an ID from on_* and remove the matching function from the vector.
Why do I get “recursive use of an object detected”? A callback called back into a method of the same object while the outer method still held its borrow; release borrows before invoking callbacks.
Related
- Passing closures between Rust and JavaScript — closures in depth.
- Reporting progress from Wasm to the UI — batching progress.
- Creating views into Wasm memory safely — view lifetimes.
- Measuring JS-to-Wasm call overhead — boundary costs.