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-sys features 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.

A Rust closure used as an event listener Rust creates a Closure, which allocates the captured state on the Wasm heap and produces a JavaScript function. The function is registered as a listener. Each click calls into Wasm with the closure's pointer. When the Closure is dropped, the heap state is freed and the listener must already be removed. Rust glue + JS function DOM Closure::new(move |e| …) add_event_listener("click", f) click → f(event) call closure on Wasm heap remove listener, then drop Closure

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.

Closure lifetime strategies Storing the Closure in an owning struct ties its lifetime to that object and is the default. Closure::once frees itself after the single call. forget leaks it intentionally and is only safe for callbacks that live as long as the page. Dropping early leaves JavaScript with a dangling function. strategy freed when use for store in owning struct owner is dropped listeners, intervals, components Closure::once / once_into_js after its single call timeouts, one-shot events forget() never page-lifetime handlers only drop immediately now — JS call then fails never

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 Closure while 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::once that 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 Rc to shared state instead of copies.
  • Borrowing RefCell state 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.

Wasm heap growth after 100,000 component mounts Megabytes of Wasm heap growth after mounting and unmounting a component one hundred thousand times, when its closures are stored and dropped, when created with once, and when leaked with forget. MB of heap growth stored and dropped 0 MB Closure::once (all fired) 0 MB forget() on each mount 9.6 MB

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.

← Back to wasm-bindgen Deep Dive