Skip to main content

Rust Wasm-Bindgen: Debugging 'Recursive use of an object' Panics in Async Closures

 If you are building complex asynchronous applications with Rust and WebAssembly, you have likely encountered this runtime panic:

Uncaught Error: Recursive use of an object detected which would lead to unsafe aliasing in rust

This error is arguably the most notorious hurdle in the wasm-bindgen ecosystem. It halts execution immediately, often occurs in edge-case race conditions, and stems from a fundamental mismatch between Rust's compile-time ownership model and JavaScript's run-to-completion event loop.

This post dissects the memory model triggering this panic and provides the architectural pattern required to solve it reliably.

The Root Cause: The Wasm-Bindgen Borrow Guard

To understand the panic, you must understand how wasm-bindgen bridges the gap between the JS heap and the Rust linear memory.

When you define a Rust struct with #[wasm_bindgen], the generated glue code wraps the raw Rust pointer. To enforce Rust's borrowing rules (specifically: multiple immutable references OR one mutable reference), wasm-bindgen implements a runtime check similar to RefCell on the JS side.

When you call a method like fn tick(&mut self) from JavaScript:

  1. The JS wrapper marks the object as "borrowed mutably."
  2. Execution passes to WebAssembly.
  3. Upon return, the JS wrapper marks the object as "unborrowed."

The Async Trap

The failure occurs when you introduce asynchronous operations (Promises) or callbacks inside that method.

If tick(&mut self) spawns a wasm_bindgen_futures::spawn_local task or attaches a closure to a JS callback (like requestAnimationFrame) that also attempts to access self, you hit a re-entrancy issue.

If the original tick has not technically "returned" control to the JS wrapper before the callback fires, or if the generated binding code perceives the object as still locked during the await suspension points, the second attempt to borrow self sees the "borrowed mutably" flag and panics to prevent Undefined Behavior (UB).

The Fix: The Split-State Pattern using Rc<RefCell<T>>

You cannot safely pass &mut self of a #[wasm_bindgen] struct into a Future that outlives the method call. The fix requires decoupling the Binding Boundary from the Application State.

We must move the logic out of the struct that wasm-bindgen manages and into a shared state container using Rc (Reference Counting) and RefCell (Interior Mutability).

1. The Dependencies

Ensure your Cargo.toml has the necessary features:

[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console", "Window"] }

2. The Architecture

Do not implement logic directly on the exported struct. Instead, create an inner State struct and wrap it.

use std::rc::Rc;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::console;

// 1. The Pure Rust State
// This struct is NOT exported to JS. It holds the actual data and logic.
struct GameState {
    count: u32,
}

impl GameState {
    fn new() -> Self {
        Self { count: 0 }
    }

    fn increment(&mut self) {
        self.count += 1;
        console::log_1(&format!("Count is: {}", self.count).into());
    }
}

// 2. The Wasm Boundary Wrapper
// This struct IS exported to JS. It is a thin wrapper around the state.
#[wasm_bindgen]
pub struct GameClient {
    // We use Rc<RefCell<T>> to share ownership between the main thread
    // and the async closures we spawn.
    inner: Rc<RefCell<GameState>>,
}

#[wasm_bindgen]
impl GameClient {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        Self {
            inner: Rc::new(RefCell::new(GameState::new())),
        }
    }

    // This is the method that usually causes the panic.
    // Instead of using `&mut self`, we clone the Rc pointer.
    pub fn run_async_loop(&self) {
        // Clone the pointer to the state. This increases the reference count
        // but does NOT borrow the data yet.
        let state_handle = self.inner.clone();

        spawn_local(async move {
            // Simulate an async operation (e.g., fetch, timer, etc.)
            perform_js_delay(1000).await;

            // CRITICAL: We only borrow_mut() inside the future when we actually need it.
            // This borrow is short-lived.
            {
                let mut state = state_handle.borrow_mut();
                state.increment();
            } // Borrow ends here.
            
            // If we await again, the borrow is already dropped, preventing
            // the "Recursive use" panic if another event fires.
            perform_js_delay(1000).await;
            
            {
                let mut state = state_handle.borrow_mut();
                state.increment();
            }
        });
    }
}

// Helper to simulate async delay (like setTimeout)
async fn perform_js_delay(ms: i32) {
    let promise = new_promise_delay(ms);
    let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}

#[wasm_bindgen(inline_js = "export function new_promise_delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }")]
extern "C" {
    fn new_promise_delay(ms: i32) -> js_sys::Promise;
}

Why This Works

1. Decoupling Bindings from Data

The GameClient struct managed by wasm-bindgen (the one JS holds a reference to) is now effectively immutable. It only holds the Rc. We never need to call &mut self on GameClient to update the inner state. This bypasses the wasm-bindgen generated lock entirely.

2. Granular Locking

By using RefCell manually, we control exactly when the lock occurs.

  • Bad: wasm-bindgen locking self for the entire duration of a function call that includes an await.
  • Good: We borrow_mut() strictly for the microseconds required to update the struct, then immediately drop the mutable reference (implicitly at the end of the scope) before awaiting the next JS Promise.

3. Clone Semantics

The async move block captures state_handle. Because Rc is a smart pointer, state_handle is just a pointer to the heap data. Cloning it is cheap. This allows the Future to own a reference to the data independent of the GameClient instance, preventing lifetime issues where the closure outlives the struct context.

Conclusion

The "Recursive use of an object" panic is a signal that your Rust architecture is fighting the JavaScript event loop. You cannot treat Wasm structs exactly like standard Rust structs when async operations are involved.

By adopting the Split-State Pattern—separating the exported wrapper from the logic using Rc<RefCell<State>>—you gain granular control over borrowing rules, eliminate the panic, and ensure your Wasm modules play safely with the JavaScript runtime.