Waiting Without Coloring the Language: Direct-Style IO Across Native, Wasm, and the Browser
What JVM virtual threads, Rust futures, Tokio, Wasmtime, WASI 0.3, and browser JSPI teach us about simple source code and rigorous concurrency.
Reading a file should look like reading a file.
(defn load-config [source]
(json/read-string (io/read-text source)))
There is no async on load-config, no await around read-text, and no second asynchronous version of the function. The result is an ordinary Rasa value.
That is the source experience we want. It is also the easy part.
The hard part is making the same program behave correctly when a native host waits on an operating-system call, a Wasm component waits on its host, or a browser waits on a JavaScript Promise. Add concurrent work and the problem grows: failures must reach the right parent, unfinished siblings must be cancelled, cleanup must finish, and ordinary immutable values must not quietly turn into globally shared mutable objects.
Rasa's working principle is:
Source code is not colored by suspension. The compiler and runtime still
treat suspension as a precise, transitive fact.
In Rasa, a capability operation is a named, contract-checked gateway to host power. The program does not gain filesystem or network access merely because the current machine has it.
This article explains why we think that can work, what mature platforms have already taught us, and which shortcuts we intend to avoid. It describes a direction, not a claim that Rasa already has a complete concurrency system.
Three Problems That Are Easy to Mix Up
IO, concurrency, and parallelism are related, but they are not synonyms.
- IO crosses an admitted boundary to a filesystem, network, clock, browser API, or another external service.
- Concurrency lets more than one piece of work make progress during the same period. When one task waits, another may run.
- Parallelism executes work at the same instant on multiple cores or threads.
A program can perform IO sequentially. It can run many IO operations concurrently on one thread. It can run CPU work in parallel without doing any IO. Treating all three as one feature creates needless restrictions.
In particular, suspension does not imply thread movement. A task may pause and later resume on the same execution lane: one place where evaluation runs. If we require every suspended Rasa value to be safe to move to another thread, we have imposed a parallelism cost on ordinary concurrency before the program asked for parallelism.
That distinction matters because Rasa's normal values are immutable and its persistent collections use structural sharing. They are deliberately not wrapped in arbitrary Arcs, locks, or shared-mutation machinery. A value that stays on one lane should not pay for cross-thread movement.
Why Direct Style Is Worth Protecting
Callbacks, promise chains, and explicit futures can express asynchronous work. They can also split one logical operation into several pieces. The ordinary control structure disappears among adapters:
call -> register continuation -> return placeholder
later -> resume continuation -> map error
Loops, local variables, try/catch, and finally are no longer the obvious shape of the operation. Stack traces and debuggers have to reconstruct a task from pieces that may run in different places.
Java encountered exactly this pressure in high-throughput servers. Its traditional thread-per-request style was easy to read but expensive when each request occupied an operating-system thread while waiting. Asynchronous pipelines scaled better, but made sequential code and its tooling harder to follow.
JEP 444 introduced virtual threads as a way to keep the direct, thread-per-task programming model without holding an OS thread for the whole task. When a supported operation waits, the virtual thread can unmount from its carrier thread. Another virtual thread can use that carrier. The application still reads like sequential code.
The strongest lesson is not "Rasa needs Java threads." It is this:
A runtime may transform waiting internally without forcing the source
language to expose that transformation at every call site.
Virtual threads also teach two useful limits. First, they improve throughput for workloads with waiting; they do not make CPU work intrinsically faster. Second, cheap tasks should not be pooled merely to limit access to a scarce service. JEP 444 recommends a task per virtual thread and separate mechanisms, such as semaphores, for admission control. Task scheduling and resource policy are different jobs.
For Rasa, the equivalent separation is even sharper. A scheduler decides when admitted work runs. A capability contract decides whether the work may happen at all, against which provider, with which authority and limits.
Cheap Tasks Are Not Enough
Once tasks become cheap, it becomes easy to create many of them. It does not become easy to manage their lifetimes.
Suppose a request starts two operations: load an account and load its recent orders. If loading the account fails, what happens to the order request? If the parent is cancelled, who tells both children? Can either child keep running after the request has returned? Which failure is reported if cleanup also fails?
An unrestricted task handle can escape anywhere. A task started by one function may be awaited by unrelated code, forgotten, or left running after its logical parent is gone. The code has a parent-child relationship, but the runtime does not.
Java's structured-concurrency work explains this failure mode clearly. In the current JEP 525, following the substantial API shape developed through JEP 505, child tasks live inside a lexical scope. The scope does not finish until its children have reached terminal states. A task tree becomes the concurrent counterpart of a call stack, so cancellation, deadlines, errors, and observability can follow the same structure programmers see in the code.
The exact Java API is not Rasa's answer, and it is still a preview API in JDK
- The invariant is more important than the spelling:
parent scope
├── account operation
└── orders operation
scope exit is allowed only after both children are terminal
Rasa's future concurrency surface should follow that rule. Concurrency must be explicit because it changes ordering and failure behavior, but it should be explicit in one small lexical construct, not by coloring every function it can reach. We have not selected that source syntax yet.
Cancellation must also be described honestly. A cancellation request is not a proof that work stopped. A provider may need to interrupt an operation, receive its terminal result, and release a resource. A lexical scope cannot simply discard its children and declare success.
The Pinning Lesson: Transparent Does Not Mean Magical
The original virtual-thread implementation had an important weak point. A virtual thread could become pinned to its carrier while inside certain synchronized regions or native and foreign calls. If it then blocked, the OS thread remained blocked too. The source code still looked lightweight, but a boundary defeated the runtime's scheduling model.
JEP 491, delivered in JDK 24, removed nearly all pinning caused by Java monitors and improved diagnostics. Native and foreign boundaries remain a reason for care.
There are two lessons here.
First, transparent suspension depends on every boundary it crosses. A language runtime cannot label the top-level feature "nonblocking" and ignore a provider that blocks its only execution lane.
Second, boundary failures need evidence and diagnostics. JEP 444 added events and stack traces for pinned virtual threads before the implementation could remove the main cause. Rasa should likewise be able to explain which capability may suspend, which values remain live, which provider is selected, and why a target must block or refuse.
A helpful refusal is better than a hidden fallback:
cannot suspend at rasa.io/read-text
because local mutable value `builder` remains live
through load-config -> read-config -> rasa.io/read-text
What Safe Rust Gives Us
Rust's standard library supplies a small and powerful protocol for delayed work. A <code>Future</code> is polled. It returns Poll::Ready(value) when complete or Poll::Pending when it cannot make progress now. A pending future arranges to be woken when polling again may help.
This is excellent implementation material for Rasa. It gives us:
- an explicit ready-or-pending transition;
- nonblocking integration with host event sources;
- ordinary Rust ownership around state retained while waiting;
- a safe interface whose polling cannot cause undefined behavior.
It does not give us a Rasa task model.
The Rust documentation is explicit that futures are inert until something polls them. Future does not choose a scheduler. Dropping a future is not, by itself, Rasa's cancellation contract. Pin is an implementation constraint, not a source-level lifecycle. A Waker says that progress may be possible; it does not decide which Rasa scope owns the work.
Safe Rust can carry a different and equally important burden: proving that runtime states are not duplicated or misused. A Rasa activation can be a private, move-owned state machine with exhaustive states such as:
running -> waiting -> running -> terminal
\-> terminal
Transitions can consume the old state. Retained values can use narrow private types that prove they are legal across suspension. Ownership-based destruction, often called RAII, can protect locally owned cleanup. Rust moves and borrows can prevent two pieces of code from both claiming unique ownership.
This is where we want to lean hardest on Rust: exact implementation properties that safe Rust can prove once. Rasa still owns facts Rust cannot infer, such as whether a capability was admitted, whether a provider's cancellation behavior matches its contract, or whether a live named definition, called a Var, still has the compiler evidence used to authorize its call.
Rust also contains a compact example of structured lifetime. With <code>std::thread::scope</code>, scoped threads may borrow local data because the type and lifetime structure guarantees they are joined before the scope returns. Rasa will not copy this API, but the proof shape is valuable: constrain lifetime, then safely permit what unstructured spawning would have to reject.
Tokio: Use the Runtime Without Adopting Its Worldview
Rust's standard Future protocol deliberately does not include an IO driver, timer wheel, task scheduler, or networking stack. A production native host may need those services. Tokio is a strong candidate when that need becomes concrete.
But the common Tokio spawn operation has an important signature. A spawned future and its result must be Send + 'static. Send means Rust considers the value safe to move to another thread; 'static means the task does not borrow short-lived stack data. The runtime may move such a task to another worker thread. That is correct for Tokio's general multi-threaded scheduler. It would be the wrong reason to make every Rasa value cross-thread safe.
Tokio itself demonstrates the alternative. Its <code>LocalSet</code> runs !Send futures on one thread, while <code>tokio::spawn</code> is the explicit boundary that requires Send + 'static.
That supports a natural Rasa split:
- ordinary evaluation and concurrent IO remain lane-local by default;
- suspension on that lane does not require
SendorSync; - explicit parallel work crosses a separate movement boundary;
- Safe Rust accepts only cargo whose actual structure proves
Send; - sharing still requires a semantic authority contract, not merely an
Arc.
Arc proves that shared storage remains alive. It does not prove who may mutate it, whether a Rasa value is allowed to move, or how cleanup and cancellation work. A lock is useful when there is real admitted shared mutation. It is not a general-purpose entrance ticket to concurrency.
Wasmtime: An Embedding Mechanism, Not Language Semantics
Wasmtime supports Rust-level asynchronous host functions and asynchronous Wasm execution. Its embedding documentation describes *_async call variants that let a guest yield without blocking its host. Wasmtime represents the running guest as a Rust future; the embedder still supplies the executor and decides how compute time is controlled.
This is useful, but it should stay below Rasa's semantic contract.
The same Rasa IO call may be lowered to a native future, a Wasmtime async host call, a blocking adapter in a deliberately limited product, or a structured refusal. Those mechanisms do not get to change what the call returns, which error it raises, whether cleanup runs, or what cancellation means.
The distinction becomes especially important with components. WASI 0.3, the WebAssembly System Interface, moves asynchronous composition into the WebAssembly Component Model. The WebAssembly Interface Type language, WIT, can describe async func, future<T>, and stream<T>, and the runtime carries scheduling and wake-up propagation across chains of components.
WASI 0.3 fixes a real composition problem from WASI 0.2: a middle component could not reliably forward a component-local pollable's wake-up to its caller. Putting async in the Canonical ABI, the component calling convention, lets the runtime preserve that chain.
This is a great platform facility. It still does not define Rasa IO. A WIT future does not decide Rasa's source return type. A WASI stream does not decide backpressure, failure, or cleanup for a Rasa stream. Rasa should use those primitives only after the source operation's own contract is complete.
The timing matters too. WASI 0.3.0 was released in June 2026, and its own migration guidance warns that toolchain versions must agree. A portable language should adopt this substrate through pinned, tested host profiles, not assume that every Wasm environment supports it equally today.
The Browser: Let Promises End at the Boundary
Browsers present the inverse problem. Many useful Web APIs already return Promises, while Wasm code often expects a synchronous-looking call.
The WebAssembly JavaScript Promise Integration proposal provides a bridge. A WebAssembly.Suspending import can pause a Wasm computation when a JavaScript function returns a Promise. A WebAssembly.promising export gives JavaScript one Promise for the eventual Wasm result. When the host Promise settles, the browser resumes the suspended Wasm computation.
That is almost exactly the physical shape direct-style Rasa needs in a browser:
Rasa call
-> Wasm capability import
-> admitted JavaScript provider
-> browser Promise
<- resume Wasm with value or failure
<- ordinary Rasa value or Error
JSPI is a boundary mechanism, not a portable scheduler and not a capability system. It applies only where the engine supports it and where imports and exports are projected correctly. A browser without the required feature should receive a structured compatibility refusal before executing the affected program, not a hidden busy-wait or a frozen main thread.
Rasa's browser product already contains a JSPI projection for Promise-returning capability providers. That is useful evidence for this direction. It is not yet the complete task, cancellation, and cleanup model described in this article.
One Meaning, Different Machines
Uniform behavior across native, Wasmtime, and browser hosts should not mean one physical runtime everywhere. It should mean one observable Rasa contract.
For an admitted operation, the hosts must agree on:
- evaluation and effect order;
- the returned Rasa value or stable Rasa Error;
- when authority becomes active;
- which values and resources may survive suspension;
- cancellation and terminal completion;
- cleanup and
finallybehavior.
They do not need the same event loop, stack representation, provider, or IO driver.
This separation keeps target details from leaking into source code. A browser filesystem is not a native filesystem in disguise. If two providers cannot preserve one operation's meaning, Rasa should expose distinct capabilities or refuse the operation. Product-specific conditionals should not be required to make one supposedly portable call correct.
What Rasa Has Today
Rasa today has a deliberately small IO checkpoint:
- ordinary direct-style operations for finite whole-value text IO and HTTP;
- explicit capability identities, policy, providers, and target admission;
- compiler facts that identify capability dispatch and conservatively report suspension pressure;
- working native and Wasmtime-backed provider paths;
- a browser capability host whose providers may return Promises, plus a JSPI component projection;
- structured refusals when a product cannot admit a capability.
The current generic evaluator-to-provider call boundary is synchronous. The current Wasmtime provider dispatch interface is synchronous too. Native hosts may therefore block, while the browser projection can suspend through JSPI. This proves useful source and capability shapes, but it is not a general Rasa-owned resumable execution model.
Rasa does not yet expose source tasks, task scopes, general streams, or a finished structured-concurrency policy. Full transitive suspension evidence, portable cancellation, suspending cleanup, and a move-owned resumable activation path are work ahead. We are keeping those features unavailable until their contracts agree.
The Incremental Proof Path
The safest route is not to replace the evaluator with an enormous async runtime in one change. Each step should answer one user-visible question and one proof question.
- Start with workloads: sequential IO, bounded fan-out, sibling failure, cancellation, deadlines, cleanup, and resource confinement. Record what current hosts do and where they refuse.
- Close the semantic contract before choosing syntax: lexical task lifetime, result order, failure policy, cancellation, dynamic bindings, resource ownership, and target equivalence.
- Build one controllable suspension tracer that can complete immediately, wait, fail, and receive cancellation across a native lane, Wasmtime, and the browser.
- Use that evidence to replace only the runtime seam that actually needs to become resumable. Let Safe Rust own the activation states and local safety proof.
- Deliver one bounded concurrent IO workflow across all admitted hosts.
- Add resource handles only after cleanup survives suspension. Add streams only after backpressure, cancellation, terminal failure, and cleanup have one meaning.
- Keep parallel execution as a separate boundary for explicitly movable cargo.
At each step, the platform mechanism is selected for its host: Rust futures and possibly a lane-local Tokio driver for native execution, Wasmtime and WASI component facilities for Wasm hosts, and JSPI for supporting browsers. Cross-host conformance tests judge the Rasa behavior, not whether the machinery looks the same.
The Mistakes We Want to Avoid
The platform evidence gives us a useful rejection list.
- Do not create
readandread-asyncversions of the language. - Do not turn every provider wait into a source-level Future.
- Do not confuse suspension with spawning or thread movement.
- Do not allow detached tasks with unclear parents.
- Do not treat cancellation request as terminal completion.
- Do not make cheap-task pools double as capability limits.
- Do not add
Send,Sync,Arc, or locks to core values for convenience. - Do not let Tokio, WASI, Wasmtime, or JSPI define Rasa semantics.
- Do not hide unsupported behavior behind blocking, spinning, or target-specific source code.
- Do not admit streams before their lifecycle is complete.
These are deliberately harsh constraints. They reduce the number of concepts users must learn and the number of states the runtime must defend.
The Small Center
The intended design has very few central ideas:
- IO begins at an admitted capability operation.
- Source calls remain ordinary and direct.
- The compiler derives suspension and effect evidence transitively.
- Concurrent task lifetime is lexical.
- Ordinary values stay lane-local unless an explicit boundary moves them.
- Safe Rust proves the implementation properties it can express exactly.
- Each target chooses its lowering, never its semantics.
- Unsupported combinations refuse with an explanation.
The JVM shows that direct style and scalable waiting can coexist. Structured concurrency shows that cheap tasks need strict lifetime shape. Rust shows how a small ready-or-pending protocol and ownership types can carry hard proofs. Tokio shows that lane-local and cross-thread execution need not share one set of bounds. Wasmtime and WASI 0.3 show how suspension can compose through Wasm hosts and components. JSPI shows how a browser Promise can remain a boundary detail instead of coloring the language inside it.
Rasa does not need to invent replacements for those ideas. It needs to combine them under one small semantic contract, keep the contracts clean, and refuse the cases it cannot yet prove.