Uncolored Functions: Blocking I/O with WASM JSPI

AI authored to showcase ironpad capabilities.

Introduction

Heads up: this notebook needs Chrome or Edge 137+. It uses the WebAssembly JavaScript Promise Integration API (JSPI), which Firefox still keeps behind a flag and Safari has not shipped yet. On those browsers the code cells below will stop with a message saying exactly that.

Rust folks know the "What Color Is Your Function?" problem by heart: the moment one call does I/O, async spreads through every signature above it. Trait impls, iterator adapters, plain helper functions: none of them can await, so they can never do I/O. You restructure your program around the runtime.

JSPI deletes the color. It is a phase 4 WebAssembly proposal that lets the browser suspend an entire WASM stack while a JavaScript promise settles, then resume it exactly where it stopped. To the Rust code, a network call is just a function that takes a while, like it always was on a thread.

Every code cell in this notebook is fully synchronous Rust: not a single .await or Future anywhere. ironpad already runs async cells (see the async HTTP notebook); this one exists to show what happens when you stop needing them. The plan: sleep, measure what a suspension costs, fetch from inside iterators and trait impls, then walk the executor mechanics that keep it all sound.

The coloring problem, properly

Bob Nystrom named the problem in 2015 in "What Color Is Your Function?", and his rules for a fictional language describe async Rust precisely a decade later. Every function is red (async) or blue (sync). Red can call blue. Blue cannot call red, and that one asymmetry is the entire problem: the moment a leaf function needs I/O, every function on the path from main to that leaf turns red, one signature at a time.

Rust makes the wall taller than most languages, because so much of it is built from blue-only holes: closures handed to Iterator::map or sort_by, trait methods like Display::fmt and Drop::drop. These can never be async; they are structural commitments baked into the standard library. And the classic escape hatch is not an escape:

// Sync-to-async bridging, the traditional way. You now own an entire
// runtime, and calling this from inside an existing runtime panics.
fn title_of(stem: &str) -> Result<String, Error> {
    let rt = tokio::runtime::Runtime::new()?;
    rt.block_on(async {
        let nb: Value = get_json(&format!("/notebooks/{stem}.ironpad")).await?;
        Ok(nb["title"].as_str().unwrap_or("?").to_owned())
    })
}

Native code always had the original uncolored answer: threads. A thread that calls read() simply stops; the OS keeps its stack alive, and no signature anywhere changes. The web never allowed that, because blocking the one thread blocks the event loop, so the platform standardized on red instead. JSPI is the web finally shipping the thread-shaped answer: the engine keeps your suspended stack the way a kernel keeps a blocked thread's, while the event loop keeps spinning. Time to prove it, starting with the bluntest possible instrument.

Sleeping without an executor

What just happened

Two WebAssembly API calls, both on the JavaScript side, both invisible from Rust. Here is the actual bridge, condensed. First, any import that might block is wrapped in WebAssembly.Suspending, which turns an ordinary async JS function into an import the engine may park a WASM stack on:

imports.env.ironpad_blocking_sleep_ms = new WebAssembly.Suspending(
  (ms) => new Promise((resolve) => setTimeout(resolve, ms))
);

imports.env.ironpad_blocking_fetch = new WebAssembly.Suspending(
  async (urlPtr, urlLen) => {
    const url = readUtf8(memory, urlPtr, urlLen);
    const resp = await fetch(url);                        // stack is parked here
    stash = new Uint8Array(await resp.arrayBuffer());
    return stash.length;    // resuming the stack, this becomes the return value
  }
);

Second, the entry point is invoked through WebAssembly.promising, which is what makes that suspension legal on this stack in the first place. The synchronous WASM export becomes an ordinary promise-returning function:

const main = WebAssembly.promising(instance.exports.cell_main);
const resultPtr = await main(inputPtr, inputLen);

The two halves are a matched pair with an exact contract. promising(f) runs f on a fresh, suspendable stack and immediately hands back a Promise for the eventual return value. Suspending(g) marks an import: when WASM on a suspendable stack calls it and g returns a promise, the engine detaches every WASM frame between the promising entry and the import call, parks the segment, and returns to the event loop. When the promise settles, the segment is reattached and the import returns the resolved value to the exact instruction that called it. From inside the module, it is indistinguishable from a slow syscall.

On the Rust side there is nothing exotic at all: blocking::fetch_text is a plain extern import plus a copy. The fetch reports how many bytes arrived while the stack was parked; the cell then allocates and a second, non-suspending import copies them in:

#[link(wasm_import_module = "env")]
extern "C" {
    fn ironpad_blocking_fetch(url_ptr: *const u8, url_len: u32) -> u32;
    fn ironpad_blocking_fetch_ok() -> u32;
    fn ironpad_blocking_read(buf_ptr: *mut u8, buf_len: u32) -> u32;
}

pub fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
    let len = unsafe { ironpad_blocking_fetch(url.as_ptr(), url.len() as u32) };
    let ok = unsafe { ironpad_blocking_fetch_ok() } == 1;
    let mut buf = vec![0u8; len as usize];
    unsafe { ironpad_blocking_read(buf.as_mut_ptr(), len) };
    if ok { Ok(buf) } else { Err(String::from_utf8_lossy(&buf).into_owned()) }
}

Note what is missing: no compiler flags, no special codegen, no async runtime linked into the module. The same .wasm binary runs unmodified in any engine that ships JSPI. The async machinery moved out of your program and into the VM, which is where threads always kept it on native targets.

Sleeping is a parlor trick, though. The point of deleting function colors is doing I/O from places async cannot reach.

The price of a suspension

Where the stack goes

For a mental model, WebAssembly.promising is pthread_create and a Suspending import is a blocking syscall. The engine allocates the stack for a promising call as its own heap-backed segment, separate from the JavaScript stack that invoked it. Suspension is a stack switch, not a stack copy: save the segment's registers, flip the stack pointer back to the JS side, return to the event loop. The parked frames sit in memory untouched until the promise settles; resumption is the same switch in reverse. That is why the measurement above is all setTimeout and no JSPI.

Three rules fall out of that implementation:

  1. Only WASM frames can suspend. If the stack between the promising entry and the Suspending import contains a JavaScript frame (WASM called JS, which called back into WASM), the suspension throws instead; JS frames belong to the JS stack and cannot be detached.
  2. A parked stack is absent code, not paused code. Nothing gets locked. The instance's linear memory and exports remain fully reachable from JavaScript while it is suspended, and the engine will happily let you call back in. Whether that is sound is entirely your problem.
  3. The outer promise settles once. A single promising call can suspend and resume hundreds of times; the promise resolves only when cell_main finally returns.

Rule 2 is the sharp one, and ironpad's fetch protocol is shaped entirely around refusing to ever test it. We will get there; first, what it buys.

I/O in the middle of an iterator

Try writing that with async

The colored version of the last cell is a small essay. Iterator::map takes a plain closure, so the async rewrite abandons iterators for streams:

use futures::stream::{self, StreamExt};

let rows: Vec<Vec<String>> = stream::iter(stems)
    .then(|stem| async move {
        let url = format!("/notebooks/{stem}.ironpad");
        row_for(http::get_json(&url).await)  // and row_for's caller changes,
    })                                       // and its caller's caller...
    .collect()
    .await;

A new crate, a new vocabulary (then, not map), and the enclosing function turned red, so its caller turns red next. The I/O reshaped code that never touches the network.

Here the closure just... calls a function. The iterator has no idea the network was involved, and neither does anything else on the stack. That is what "uncolored" buys: composition with every synchronous abstraction you already have. And a closure inside map is the easy case; the next cell goes where async cannot follow at all: I/O inside Iterator::next itself.

I/O inside Iterator::next

Detection and the two-phase protocol

Nothing scans your source for blocking:: calls, and no compile flag marks the cell. Detection happens at the binary level: the executor compiles the bytes once, then inspects the module's import section for the telltale prefix. The actual code from ironpad's executor:

var JSPI_IMPORT_PREFIX = "ironpad_blocking_";

function _moduleNeedsJspi(module) {
  return WebAssembly.Module.imports(module).some(function (imp) {
    return imp.module === "env" && imp.name.indexOf(JSPI_IMPORT_PREFIX) === 0;
  });
}

Inspecting the binary instead of the source is load-bearing: the linker dead-strips unused imports, so a blocking:: call in a branch the optimizer removed leaves no import behind, and the cell stays on the ordinary path. The binary is the truth. When the scan hits and the browser lacks JSPI, execution stops before entering the module with a plain message naming Chrome/Edge 137+. When it hits and JSPI exists, the executor bypasses the wasm-bindgen wrapper and enters the raw cell_main export through WebAssembly.promising.

That covers getting in. Getting data back out is where the design earns its keep. The full exchange behind one blocking::fetch_bytes call:

cell (WASM)                            host (JS)
-----------                            ---------
ironpad_blocking_fetch(url) -------->  copy url out of linear memory
    [entire stack parks]               resp = fetch(url)  ...time passes...
                                       stash {ok, bytes} for this cell
    [stack resumes]         <--------  return bytes.length
buf = vec![0u8; len]
ironpad_blocking_fetch_ok() -------->  1 or 0 (synchronous)
ironpad_blocking_read(ptr, len) ---->  copy stash into buf (synchronous)

Only the first call suspends; the other two are plain synchronous imports. Why the dance? Because the obvious one-phase design is unsound. The tempting version writes the response directly into the cell's memory while it is still parked:

// The one-phase version. Do not do this.
const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
const ptr = instance.exports.ironpad_alloc(bytes.length); // re-entry!
new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
return ptr;

That ironpad_alloc call re-enters a suspended instance. Rule 2 says the engine allows it: the parked stack is absent, and the instance is wide open. But the second activation shares one linear memory, one allocator, and one shadow stack pointer (the global where Rust spills address-taken locals) with frames frozen mid-function. If the interleaving is kind, it composes like a nested call. If the second activation ever suspends too, resumption order decides whose spilled locals get overwritten: no trap, just wrong values two calls later. "Usually gets away with it" is not a memory model. So ironpad's host never calls back into a suspended instance: it returns only a length, the resumed cell allocates under its own stack discipline, and a synchronous import copies the bytes in afterwards.

The two cells below exercise both arms of the protocol: the error path first, then the happy path against a real cross-origin API.

Errors are just Err
A real API, still no await

Three kinds of cell

ironpad now runs three execution shapes side by side. Same compiler, same runtime crate; the difference is entirely in how cell_main is entered:

Sync cellAsync cellJSPI cell
You writeplain RustRust with .awaitplain Rust + blocking::*
Entrydirect call, returns a valueasync wrapper, returns a Promiseraw export via WebAssembly.promising
Mid-cell I/Ononehttp::* futuressuspending imports
I/O inside traits and closuresn/anoyes
Async machinery in the binarynonestate machines + executor gluenone
BrowsersallallChrome/Edge 137+
Cost per I/O calln/apoll-loop wakeupsone stack switch + one event-loop hop

One footnote worth having: the async column is triggered by a substring. A cell whose text contains the literal .await anywhere, even inside a string, compiles to the async wrapper, and that wrapper cannot be entered through WebAssembly.promising; a JSPI cell keeps the substring out entirely. The lane is per cell, not per notebook, so one notebook can mix all three columns freely.

Support and sharp edges

EngineJSPI status
Chrome / Edgeshipped, 137+ (mid-2025)
Firefoximplemented, behind a flag through 155
Safariin development (objection withdrawn late 2025)

JSPI is one of the Interop 2026 focus areas, so the missing engines have a public deadline of sorts. Until then, ironpad detects the ironpad_blocking_* imports at load time and fails before execution with a plain-language message on unsupported browsers, rather than letting the cell trap.

Three rules to keep out of the weeds:

  • Sync cells only. A cell containing .await compiles to an async entry point, which cannot be entered through WebAssembly.promising. Pick a lane per cell: blocking::* or .await, never both.
  • Keep suspensions coarse. Every blocking call is an event-loop round trip, a few milliseconds as measured above; a thousand tiny calls are several seconds of pure hops. Fetch once, parse locally.
  • Fetches follow browser rules. Same-origin is always fine; cross-origin needs CORS from the server, exactly like the async http module.