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.

Ways to pass callbacks and when to use them A borrowed typed closure parameter is checked at compile time and valid only during the call. An owned js_sys Function can be stored and called later with JsValue arguments. A wasm-bindgen Closure wraps a Rust closure for JavaScript to call and must be kept alive or forgotten deliberately. mechanism direction lifetime typing &dyn Fn(u32, &str) parameter JS fn → Rust during the call only typed js_sys::Function (owned) JS fn → Rust stored as long as needed JsValue args Closure<dyn FnMut(T)> Rust fn → JS until dropped / forgotten typed

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.

Cost per callback invocation by argument type Nanoseconds per callback call from Rust into JavaScript, with two numbers, with a short string, with a 1,000-element f32 slice copied as a new array, and with a view over linear memory. ns per invocation two u32 numbers 25 ns short &str (decode) 110 ns &[f32] × 1000 (copy) 900 ns Float32Array view over memory 60 ns

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 a js_sys::Function to 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.

Callback overhead for a million-item job Milliseconds of boundary overhead from progress callbacks during a job processing one million items, calling back per item and calling back once per ten thousand items. ms of callback overhead per item 31 ms per 10, 000 items 0.0 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.

← Back to Passing Complex Types Across the Boundary