Rust on Workers: From fatal panics to recoverable failures

Rust Workers on Cloudflare’s platform compile to WebAssembly, and for a long time that meant a single panic or abort could take down more than just the request that caused it. The root problem lived in wasm-bindgen, the project that generates the Rust-to-JavaScript bindings: it had no recovery semantics built in, so an unhandled abort could poison the Wasm instance and affect sibling or even incoming requests.

Recent work has changed that. The latest Rust Workers tooling now has comprehensive Wasm error recovery, with changes contributed upstream to wasm-bindgen. The effort proceeded in two stages: first, panic=unwind support so a panicking request doesn't corrupt others, and second, abort recovery that prevents Rust code on Wasm from re-executing after a fatal error.

BLOG-3145 Hero Image

Early band-aids: custom panic handlers and reinitialization

The first reliability improvements were built directly into workers-rs rather than upstream. A custom Rust panic handler tracked failure state inside a Worker and forced a full application reinitialization before the next request. On the JavaScript side, all Rust-JavaScript call boundaries were wrapped with Proxy-based indirection, and generated bindings were modified to correctly reinitialize the WebAssembly module after a failure.

That approach shipped by default to all workers-rs users in version 0.6. It proved that recovery was viable and eliminated the persistent failure modes seen in production, while setting the stage for the more general mechanisms that followed.

Panic unwinding with WebAssembly Exception Handling

Reinitializing the whole application works for stateless request handlers, but not for stateful workloads like Durable Objects. A single panic could wipe in-memory state that other concurrent requests depend on. Native Rust handles this via unwinding, which runs destructors and lets the program continue. WebAssembly has historically lacked that option: Rust compiled to wasm32-unknown-unknown defaults to panic=abort, so a panic becomes an unreachable trap that exits Wasm as a WebAssembly.RuntimeError.

Recovering without discarding instance state required panic=unwind support for wasm32-unknown-unknown in wasm-bindgen, made feasible by the WebAssembly Exception Handling proposal that gained broad engine support in 2023.

Building with RUSTFLAGS='-Cpanic=unwind' cargo build -Zbuild-std rebuilds the standard library with unwind support. For instance:

struct HasDropA;
struct HasDropB;
extern "C" {
    fn imported_func();
}

fn some_func() {
    let a = HasDropA;
    let b = HasDropB;
    imported_func();
}

compiles to WebAssembly as:

try
  call <imported_func>
catch_all
  call <drop_b>
  call <drop_a>
  rethrow
end
call <drop_b>
call <drop_a>

With this, even a panic inside imported_func() still runs destructors. Similarly, std::panic::catch_unwind(|| some_func()) becomes:

try
  call <some_func>
  ;; set result to Ok(return value)
catch
  try
    call <std::panicking::catch_unwind::cleanup>
    ;; set result to Err(panic payload)
  catch_all
    call <core::panicking::cannot_unwind>
    unreachable
  end
end

Making this work end-to-end required changes across the wasm-bindgen toolchain. The Walrus WebAssembly parser needed try/catch instruction support, and the descriptor interpreter had to evaluate code containing exception handling blocks.

The final piece was modifying wasm-bindgen's generated exports to catch panics at the Rust-JavaScript boundary and surface them as JavaScript PanicError exceptions. One subtlety: Rust aborts when unwinding through extern "C" functions after catching a foreign exception, so exports needed extern "C-unwind" to explicitly permit unwinding. For async exports, a panic rejects the JavaScript Promise with a PanicError.

Closures also demanded attention. A new MaybeUnwindSafe trait checks UnwindSafe only when built with panic=unwind — but this exposed a problem. Many closures capture references that remain valid after an unwind, making them inherently unwind-unsafe. Rather than pushing users toward AssertUnwindSafe, wasm-bindgen added Closure::new_aborting variants that terminate on panic when unwind safety can't be guaranteed.

With panic unwinding enabled:

  • Panics in exported Rust functions are caught by wasm-bindgen
  • Panics surface to JavaScript as PanicError exceptions
  • Async exports reject their promises with a PanicError
  • Rust destructors run correctly
  • The WebAssembly instance remains valid and reusable

Handling the unrecoverable: abort detection and guards

Aborts still happen even with unwind support — out-of-memory errors being a common cause. State recovery is impossible after an abort, but future operations can still be protected from executing against an invalid instance.

Panic unwind support complicated things here. When Wasm returns an error, distinguishing a genuine abort from a foreign extern "C-unwind" exception isn't obvious. Aborts take many shapes in WebAssembly.

The team chose to tag foreign exceptions rather than aborts, since the exception handling code already used raw WebAssembly text format (WAT) instructions. An Exception.Tag in the Wasm exception handling proposal cleanly separates recoverable unwind exceptions from fatal aborts.

With that distinction in place, two new mechanisms were integrated. An abort hook, set_on_abort, can be attached at initialization time to perform platform-appropriate recovery. Additionally, abort reentrancy guards prevent re-execution after an abort.

This matters because WebAssembly allows deeply interleaved call stacks: Wasm can call into JavaScript, which re-enters Wasm at arbitrary depths, all while multiple tasks share the same instance. Previously, an abort in one task or nested stack wasn't guaranteed to invalidate higher stacks through JS, which could lead to undefined behavior. The guards ensure execution correctness — a single failure doesn't cascade into multiple failures.

Reinitialization for wasm-bindgen libraries

The same abort problem afflicts any JS application that imports a Rust library built with wasm-bindgen. When a Wasm library is linked and initialized, there's no obvious recovery path if an abort occurs during a normal function call.

To address this, wasm-bindgen gained an experimental reinitialization mechanism, --reset-state-function. This exposes a function that lets the Rust application reset its internal Wasm instance to its initial state for the next call — without requiring the JS consumer to reimport or recreate the bindings. Class instances from the old instance throw as orphaned handles, but new classes can be constructed. The application errors, but it isn't bricked.

The wider ecosystem problem: legacy vs. modern exception handling

The upstream contributions go beyond wasm-bindgen itself. Building for Wasm with panic=unwind still requires an experimental nightly Rust target, and part of the work is advancing Rust's Wasm support for WebAssembly Exception Handling toward stable.

A late-stage specification change split exception handling into two variants: the deprecated legacy form and the modern "exnref" version. Rust's Wasm targets still default to emitting legacy code. The larger concern was platform support: Node.js 24 LTS would have kept the ecosystem on legacy exception handling until April 2028.

Runtime

Version

Release Date

v8

13.8.1

April 28, 2025

workerd

v1.20250620.0

June 19, 2025

Chrome

138

June 28, 2025

Firefox

131

October 1, 2024

Safari

18.4

March 31, 2025

Node.js

25.0.0

October 15, 2025

Backports resolved this. Modern exception handling was backported to Node.js 24 and even to the Node.js 22 release line, which should let the modern proposal become the default target next year. The goal is a seamless transition to stable panic=unwind and modern exception handling for end users.

Using panic unwind in Rust Workers

Rust Workers version 0.8.0 introduces a --panic-unwind build flag. With it, panics recover fully, and abort recovery uses the new abort classification and recovery hook mechanism. The plan is to make panic=unwind the default in a subsequent release. Users staying on panic=abort continue to get the custom recovery wrapper handling from 0.6.0.

Hardening the Wasm runtime path

The stabilisation work on Rust Workers centres on fixing the fundamental sharp edges of the WebAssembly platform itself. Panic and abort handling was one of the most visible gaps: when a Rust worker failed, the failure mode depended on how the runtime compiled the code, and the default was rarely the one you wanted.

Why a Rust panic used to mean a broken worker

Rust's wasm32-unknown-unknown target has no unwinding support. That means any panic hits an abort handler, and the Wasm module is left in a state where it can never run again. Once a worker panicked, subsequent requests to the same isolate would fail — even if the panic happened in a background task unrelated to the current request.

Catching the panic at the JS boundary helped, but it was not complete protection. With an unhandled panic or an explicit abort(), the runtime still tears down the whole isolate. The problem was that the generated panic handler wasn't communicating the real condition of the worker back to the JS layer; it just terminated the module.

Restoring the worker instead of dropping it

The fix lives in the code that wasm-bindgen generates when a Rust module is instantiated. The toolchain now wires an abort handler that can be trapped safely at the JS level, and the runtime intercepts that trap. What matters is the side effect: the abort is recoverable, and the isolate can continue serving requests even after the panic was signalled.

This behaviour is not specific to any particular libc shim. The change applies to both wasm-bindgen-generated bindings and code built with a raw #[no_mangle] extern "C" entry point, as long as the abort trap is what actually fires. The recovery logic lives in the JS glue around the module, listening for the trap and turning it into a handled JavaScript exception rather than letting the runtime kill the isolate.

Deliberate abort() calls — like the ones the Rust standard library emits for overflow checks or allocation failure — follow the same path. Previously they would have taken down the whole isolate; when using the panic-catching workers with the structured panic handler, abort becomes a controlled failure point that is recorded and then the isolate is left able to respond.

Divergent behaviour with and without panic catching

There is still a meaningful difference in the signals that are sent back to the client depending on which execution mode you are in. With a panic-catching worker, a trapped abort returns HTTP 500 with the panic message; in a non-panic-catching worker, the same abort goes through as the raw "RuntimeAbort" message.

Because Rust's standard panic hook always writes to the console before the abort fires, you may see both the hook output and the "RuntimeAbort" line in the logs for a non-caught abort. This is also why you might see a single panic reported twice — one time as the script error path (the default called from the generated code) and again when the runtime abort bounces back to the script handler.

The support covers console_error_panic_hook and custom panic hooks, in addition to the default hook provided by the Rust standard library and the custom Rust std/core provided for Workers that allocates primitives.

Explicitly returning Result::Err from an entry point is still the cleanest way to handle operational errors. But abandoning a request should no longer be an option for permanently killing a worker: the unit of failure has been reduced from the isolate down to the individual execution.

Some abort conditions still trigger the classic uncatchable failure, including calls to std::process::abort and machine-level trap instructions like unreachable. Those cannot be intercepted reliably because they aren't routed through the same definition of the abort handler.