Cloudflare has published the first public experimental preview of support for the Emscripten wasm32-unknown-emscripten Rust compiler target in wasm-bindgen and its Rust Workers. The work has been a long-term effort: Google initiated it over a year ago, and Cloudflare engineers maintaining wasm-bindgen later reviewed and supported it. Although still pre-release, the target opens up new workflow possibilities for running native wasm-bindgen Rust applications with Emscripten on the web, Node.js, and the Workers platform.
Testing shows significantly improved library compatibility for Rust Workers. To demonstrate the supported capabilities, Cloudflare got a Rust-native Minecraft server (Pumpkin) running inside a Durable Object with TCP ingress, using real TCP sockets via Tokio.
Emscripten is an open-source WebAssembly compiler toolchain created by Mozilla and now maintained by Google engineers. It bridges native code with the web platform and virtualizes platform features such as timers, file system operations and sockets. Because Workers support Web Platform APIs and Node.js compatibility, Emscripten can be supported on Workers through its Node.js compilation flags, virtualizing those native features on top of the existing Node.js APIs. With the Tokio work, Rust code built on Tokio's async runtime can be integrated into JavaScript host environments through this target as well.
Experimental patchsets and example applications are available today:
- Building Emscripten Rust Workers
- Running the Tokio async runtime in a Worker
- TCP Sockets on Workers with Emscripten and Tokio
How the two toolchains learned to cooperate
Mitch Foley of Google's Portable Toolchains team first identified the need for Rust WebAssembly toolchains to interoperate with C++ when another internal Google team wanted to use wasm-bindgen to interface with JavaScript. The goal was for wasm-bindgen to drive the build and produce the companion JavaScript, while Emscripten's linker (wasm-ld) linked in any required C++ dependencies.
The conflict was structural: both tools assume they control loading and interacting with JavaScript and generating the final JS and Wasm output. Internal users therefore had to pick one toolchain and forgo the other, and supporting both sets of tooling would have doubled the support surface for Google's Portable Toolchains team.
Mitch Foley and Yifan Yang devised a cooperative split: Emscripten would continue to drive the build, load the Wasm module and provide the companion JS, while wasm-bindgen would emit a smaller portable version of its JavaScript bindings that could be included directly in Emscripten's library system. This required buy-in from the maintainers of both projects, since each would have to maintain integration tests that depended on the other. With feedback from Google's Portable Toolchains team, Google's Wasm Tools team and Cloudflare engineers, the changes landed in both projects.
The result is interoperability between the toolchains under the new -sWASM_BINDGEN configuration:
- C++ Emscripten code driven by Emscripten's compiler can be built against static wasm-bindgen Rust code, with the wasm-bindgen bindings layer supported alongside the Emscripten bindings layer.
- Rust applications using wasm-bindgen and driven by Rust's compiler can be built for the Emscripten target, with Emscripten's bindings layer supported alongside wasm-bindgen's bindings layer.
Details on using the target are in the wasm-bindgen Emscripten documentation.
Library support and Tokio
Once wasm32-unknown-emscripten target support landed in wasm-bindgen, Cloudflare engineers built early prototypes that demonstrated clear success on Workers. Many libraries worked out of the box, including low-level systems libraries, because Emscripten already supports the target_family = unix in Rust.
A few low-level systems libraries were unaware of Emscripten and needed patches, among them libc, socket2 and Mio. These patches mainly added the Emscripten target to existing platform gates, for example by explicitly allowing target_os = “emscripten”, in place of Wasm platform gates. Such patches were posted infrequently and were mostly trivial; library maintainers reviewed them readily. The one foundational library that needed significant work was Tokio.
Tokio on a JavaScript event loop
Workers are single-threaded and hosted inside a JS event loop, whereas Tokio's async design assumes blocking operations can be supported through threaded parking semantics. The models are incompatible: a pending socket read or an epoll wait cannot be allowed to block the shared JS event loop.
Cloudflare pursued two approaches: WebAssembly JavaScript Promise Integration, and modifying Tokio to integrate with the event loop runtime. It contributed full Tokio patchsets that are under upstream review, and the first target support patch for wasm32-unknown-emscripten has landed upstream in Tokio. Both approaches are fully supported in Workers; the Rust Workers Tokio examples currently require the patches directly pending further upstream integration.
JSPI and reentrancy
WebAssembly JavaScript Promise Integration maps directly onto Tokio's existing parking semantics: JSPI lets a blocking Wasm call suspend the WebAssembly stack on a synchronous operation and return control to the JS event loop, exactly as a park would. When a new Wasm call arrives while an earlier one is suspended, JSPI allows the new stack to be entered while arbitrary existing stacks remain suspended.
The difficulty is that Rust does not know its stack is being switched out from under it. Tokio tracks its runtime context in a thread local, and a JSPI stack switch is not a thread switch, so suspended and new stacks share the same thread-local runtime context. The runtime context therefore still believes it is inside the parked context when the new Wasm call enters, and panics because the runtime is already entered.
Fully reentrant JSPI requires splitting context across JSPI context switches by swapping the thread-local context on every enter, exit, suspend and resume — in effect cooperative time-multiplexed threading, with each suspended stack carrying its own runtime context. Cloudflare implemented and verified this model with its pre-release Tokio patchset; finalizing and upstreaming the design continues in collaboration with the Tokio and Emscripten teams.
A LocalEventLoop runtime for Tokio
The alternative is full event loop integration. Event loops are a familiar paradigm in native UI applications, so maintainers in the Emscripten discussions were not unfamiliar with the idea — the question was how to design one for Tokio that works across native applications (including Windows and macOS) as well as for WebAssembly embeddings in JavaScript hosts. A general LocalEventLoop runtime would solve the problem more broadly.
A Tokio runtime does two things in a loop: it polls tasks until nothing is ready, then it waits. The wait is what makes it a runtime rather than a library, since the thread parks inside the I/O driver until a socket becomes readable, a timer expires, or another thread wakes it. When the host already has its own event loop, Tokio's loop cannot run inside it without blocking the host's.
The solution splits the loop in two, with both parts able to integrate with the host. Polling becomes an explicit drive() operation that runs one batch of ready tasks and returns. The wait is replaced by a wake: instead of parking, the runtime tells the host it has work, and the host calls drive() when it is ready.
The proposed LocalEventLoop design is a LocalRuntime whose wait has been replaced by a wake, built around a standard std::task::Waker owned by the host. Instead of signaling that a future should be polled soon, that waker signals that the runtime itself should be driven soon.
Under a regular Tokio runtime, a socket read looks like this:
let rt = Builder::new_current_thread().enable_all().build()?;
rt.block_on(async {
let mut stream = TcpStream::connect(addr).await?;
let mut buf = [0u8; 1024];
// thread parks in epoll_wait here
let n = stream.read(&mut buf).await?;
Ok::<_, io::Error>(())
})?;
with the corresponding call diagram:

With LocalEventLoop, the same code becomes:
let el = Builder::new_current_thread()
.enable_all()
.build_local_event_loop(Default::default(), host_waker)?;
el.spawn_local(async {
let mut stream = TcpStream::connect(addr).await?;
let mut buf = [0u8; 1024];
// control flow returns to the host while waiting
let n = stream.read(&mut buf).await?;
Ok::<_, io::Error>(())
});
Here spawn_local returns immediately, and the host event loop takes responsibility for driving the spawned task to completion via el.drive() calls from the host. When stream data is unavailable, the runtime simply returns control to the host. Once the socket becomes readable, Tokio uses the host_waker to signal that the runtime needs driving.

Everything that would have unparked a native runtime's thread — a spawn, a task woken from another thread, a socket becoming readable, a timer expiring — wakes the host instead. Because a Waker is Send + Sync and carries no execution semantics, its implementation is merely a notification to the host's event loop, which makes the contract safe by construction: a wake arriving from another thread, from a host callback, or even during a drive queues work rather than re-entering the runtime. The drive that follows runs on the owning thread with nothing else on the stack.
The one thing that is not possible is waiting. block_on still exists and runs a future as far as ready work carries it, but where a normal runtime would park, LocalEventLoop::block_on panics, because nothing could wake that future from inside the call — its wait belongs to the host.
With this design the host event loop is never blocked and interleaves its own work with Tokio's one batch at a time. The same architecture embeds into a GTK main loop, a Win32 message pump or a Cocoa run loop, and any number of these runtimes can co-exist thanks to their cooperative execution semantics.
Closing the networking gap: epoll and sockets
With the Tokio runtime paths in place, most of the Tokio test suite could run, except for the net feature: its TCP, UDP and Unix socket APIs. Tokio's I/O driver, built through mio, relies on epoll_wait(), while Emscripten offered only poll() and a WebSocket emulation layer. Full integration with Workers TCP sockets — including forthcoming inbound TCP — required a bridge between Emscripten's virtualization layer and the Workers sockets layer.
That bridge already existed in the form of the node:net API supported by the Workers Node.js compatibility layer. Emscripten's -sNODERAWFS mode had set the precedent by bridging into Node.js FS APIs, so the same pattern could expose sockets without custom APIs on either side. The result, contributed across more than 40 Emscripten pull requests, is the -sNODERAWSOCKETS compilation option, which brings epoll, TCP, UDP and Unix socket support to Emscripten applications running in Node.js and, since Workers implements the same node:net API, on Workers too.
Readiness handling differs by runtime strategy:
- JSPI:
epoll_wait()in Emscripten simply suspends the stack until readiness arrives, so Tokio's I/O driver behaves as it does natively. - LocalEventLoop: readiness has to reach the Waker from a JS callback. A drafted Emscripten proposal adds
emscripten_epoll_add_listenerto associate a callback with an epoll's ready events; the followingdrive()collects those events with a zero-timeoutepoll_wait(), leaving Tokio's existing I/O driver unchanged.
Case study: a Minecraft server in a Durable Object
To exercise the target, the challenge was raised of deploying a Minecraft server to Workers. Dan Lapid built Pumpkin, a Rust Minecraft server on Tokio, inside a Durable Object in a single weekend.
Pumpkin is designed for multi-core machines: world generation on a dedicated thread pool, and the game tick loop and chunk scheduler each on their own OS threads. A Durable Object has exactly one thread, so those threads had to become cooperative tasks on the event loop. Through the Tokio integration, the tick loop and chunk scheduler became async tasks and each Rayon job became a Tokio task. World generation runs on the event loop itself, taking one turn per chunk and interleaving with network I/O and game ticks instead of blocking them.
Persistence goes through ordinary std::fs. With Emscripten's -sNODERAWFS, file system calls are forwarded to the Workers node:fs compatibility layer — a JavaScript bridge, and therefore easy to replace. Dan's worker-fs-mount library mounts a node:fs-compatible file system backed by durable-object-fs, which stores files as rows in the Durable Object's SQLite storage. Every save becomes a row written synchronously and committed with the object's transaction; Pumpkin is unaware it is not writing to disk, and a restarted object boots from the same world.
Networking likewise needed no changes in Pumpkin. Each player connection arrives through Workers TCP ingress at the Worker's connect() handler, which forwards it to the Durable Object. There, handleAsNodeConnection() from cloudflare:node dispatches the socket to a net.Server listening on that port inside the object — a TCP counterpart to handleAsNodeRequest(). The -sNODERAWSOCKETS backend implements TcpListener on top of net.Server, so the server accepts players just as it would on Linux, and the returned promise signals when the last player has left so the object can save and shut down.
A fully persistent multiplayer Minecraft server running in a Durable Object illustrates how much native compatibility this Emscripten target makes possible.
Availability
The full patchsets and workflows are available today for experimental use. Contributions and feedback are welcome on GitHub and in the #rust-on-workers channel on Cloudflare's Discord.



