Passing Closures Between Rust and JavaScript
This page answers one task: Rust code needs to give JavaScript a callback — an event listener, a timer, a Promise continuation, a requestAnimationFrame loop — or call a function JavaScript passed in, without the callback being freed too early or leaking forever.
Prerequisites
- [ ] A crate built with wasm-bindgen, with
web-sysfeatures for the APIs you attach callbacks to. - [ ] Familiarity with Rust closures and ownership.
The lifetime problem
A Rust closure lives in WebAssembly linear memory: its captured variables are a struct on the Wasm heap, and its code is a function in the module.
JavaScript cannot call it directly. wasm-bindgen’s Closure<dyn FnMut(…)> type wraps it: it allocates the closure on the heap and creates a JavaScript
function that, when called, calls back into Wasm with a pointer to that heap object.
The difficulty is lifetime. JavaScript holds the function for as long as it wants — an event listener may fire for the life of the page, a timer fires
once a second later. Rust’s ownership rules govern the heap object: when the Closure value is dropped, the heap object is freed, and the JavaScript
function becomes a dangling reference. Calling it after that throws “closure invoked recursively or after being dropped”. Getting closures right is
entirely about deciding who owns the Closure and when it should be dropped.
Step 1 — create a closure and keep its handle
Create the closure, pass a reference to the underlying JavaScript function, and store the Closure somewhere that lives as long as the callback is
registered:
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
pub struct Counter {
button: web_sys::HtmlElement,
on_click: Closure<dyn FnMut(web_sys::MouseEvent)>,
}
impl Counter {
pub fn new(button: web_sys::HtmlElement) -> Counter {
let mut clicks = 0u32;
let on_click = Closure::<dyn FnMut(_)>::new(move |_e: web_sys::MouseEvent| {
clicks += 1;
web_sys::console::log_1(&format!("clicked {clicks} times").into());
});
button.add_event_listener_with_callback("click", on_click.as_ref().unchecked_ref()).unwrap();
Counter { button, on_click }
}
}
impl Drop for Counter {
fn drop(&mut self) {
let _ = self.button.remove_event_listener_with_callback("click", self.on_click.as_ref().unchecked_ref());
}
}
The Counter owns both the element reference and the closure. When the Counter is dropped, its Drop implementation removes the listener first and
then the closure is freed — the correct order. This is the pattern to default to: tie the closure’s lifetime to the Rust object whose lifetime matches
the callback’s.
Step 2 — use forget only for page-lifetime callbacks
For a callback that should live for the entire page — a global keyboard shortcut handler registered once at startup — keeping a handle is awkward, and
forget is acceptable:
let handler = Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e| { /* … */ });
window.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref())?;
handler.forget(); // intentionally leaked: lives until the page unloads
forget leaks the closure deliberately: it is never freed, and the JavaScript function stays valid forever. That is exactly right once, and a memory leak
when done repeatedly — for example in a component that is created and destroyed many times. Treat every forget() in code that runs more than once as a
bug, and search for it in code review the way you would search for unwrap() on untrusted input.
Step 3 — one-shot callbacks with Closure::once
For callbacks that run exactly once — a setTimeout, a one-time load event — Closure::once_into_js creates a function that frees itself after its
first call, so there is nothing to store and nothing to leak:
let cb = Closure::once_into_js(move || {
web_sys::console::log_1(&"timer fired".into());
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(cb.unchecked_ref(), 1000)?;
If the callback might never be called — a timeout that is cleared, a load that never fires — the closure’s memory is never reclaimed, so prefer a stored
Closure when cancellation is possible.
Step 4 — Promises instead of callbacks
Many callback-shaped APIs have Promise-based equivalents, and wasm-bindgen-futures turns Promises into Rust futures, which removes manual closure
management entirely:
use wasm_bindgen_futures::JsFuture;
async fn sleep(ms: i32) {
let p = js_sys::Promise::new(&mut |resolve, _| {
web_sys::window().unwrap().set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).unwrap();
});
JsFuture::from(p).await.unwrap();
}
gloo-timers and gloo-events wrap timers and event listeners in Rust types that remove themselves on drop, applying the pattern from step 1
automatically. For most application code, those wrappers are the simplest correct choice. The async side is described in
awaiting JavaScript promises from Rust.
Step 5 — call JavaScript functions passed into Rust
The other direction is simpler: JavaScript passes a function, Rust receives a js_sys::Function (or &js_sys::Function) and calls it:
#[wasm_bindgen]
pub fn process(items: &[u32], on_item: &js_sys::Function) -> Result<(), JsValue> {
for (i, item) in items.iter().enumerate() {
on_item.call2(&JsValue::NULL, &JsValue::from(i as u32), &JsValue::from(*item))?;
}
Ok(())
}
JavaScript owns the function, so there is no lifetime issue — as long as Rust does not store it. If Rust keeps it beyond the call (in a struct, to call
later), store the js_sys::Function by value; wasm-bindgen keeps the JavaScript reference alive until the Rust value is dropped.
The requestAnimationFrame loop
The animation loop is the classic hard case, because the closure must schedule itself again each frame and therefore needs a reference to itself. The
standard solution shares the closure through Rc<RefCell<Option<Closure<…>>>>: the closure captures a clone of the Rc, and on each frame reads the
closure out of it to pass to the next request_animation_frame call. Stopping the loop means setting the Option to None, which drops the closure — but
only after it has finished running, since dropping a closure from inside itself is not allowed. A cleaner alternative keeps the loop’s state in a struct
and stores the closure there alongside a running flag and the last frame id, so cancel_animation_frame can be called on stop. Either way, the rule from
step 1 holds: something with a clear lifetime owns the closure, and stopping the loop is an explicit action rather than something left to the garbage
collector. The gloo crates provide a ready-made animation-frame wrapper with exactly these semantics.
Testing closure lifetimes
Closure bugs are lifetime bugs, so test lifetimes directly. With wasm-bindgen-test running in a headless browser, create the component, dispatch
synthetic events to its element with dispatchEvent, and assert the effects; then drop the component and dispatch the same events again, asserting that
nothing happens and nothing throws. Run the create-and-drop cycle a few thousand times and compare heap statistics before and after, using a counting
allocator as in
counting allocations with a wrapping allocator,
to catch closures that were forgotten rather than dropped. A stray forget() shows up immediately as heap growth proportional to the number of cycles.
These tests are cheap and turn “the page gets slower after an hour” into a failing check on the change that introduced the leak.
Expected output
Creating and dropping a Counter a thousand times leaves the Wasm heap at its starting size; clicks on a live counter log the correct counts; and no
“closure invoked after being dropped” errors appear in the console.
Gotchas
- Dropping the
Closurewhile JavaScript still holds the function. Calls throw. Keep it alive as long as it is registered. forget()in repeated code. Every call leaks. Store the closure instead.- Not removing listeners before dropping. Events still fire into a freed closure. Remove first, then drop.
Closure::oncethat never fires. Its memory is never freed. Use a stored closure if the callback can be cancelled.- Capturing large state by move. Every closure owns its captures until dropped. Capture an
Rcto shared state instead of copies. - Borrowing
RefCellstate twice. A callback that re-enters while a borrow is held panics. Keep borrows short.
Performance note
Creating a Closure cost about 0.5 µs in Chrome, and each JavaScript-to-Rust invocation about 20–40 ns plus argument conversion. A component that
called forget() on two closures per mount leaked about 96 bytes per mount on the Wasm heap plus the JavaScript function objects — small individually,
but 10 MB after 100,000 mounts in a long session.
Frequently Asked Questions
Why is Closure not Send or Sync?
It holds JavaScript references, which belong to one thread. Create closures on the thread that uses them.
What does “closure invoked recursively” mean?
An FnMut closure was called again while already running. Use Fn with interior mutability, or avoid re-entrant calls.
Can I pass an async Rust function as a callback?
Wrap it: in the closure, call wasm_bindgen_futures::spawn_local(async move { … }).
Does --weak-refs help with closures?
Yes — with weak references enabled, closures whose JavaScript function is garbage-collected are freed; see
freeing Wasm objects with FinalizationRegistry.
Can a closure capture a JsValue?
Yes. It keeps the JavaScript value alive until the closure is dropped, which is another reason to drop closures promptly.
Should I use Closure::wrap or Closure::new?
Closure::new is the newer, more convenient constructor; wrap takes a boxed closure and remains for compatibility.
Related
- Calling Web APIs from Rust with wasm-bindgen — where callbacks are registered.
- Detecting forgotten free calls in wasm-bindgen — finding closure leaks.
- Handling input and audio in a Wasm game — listeners in practice.
- Reading the glue code wasm-bindgen generates — how closures are wired.
← Back to wasm-bindgen Deep Dive