Compile async Swift to WebAssembly, load it into a WASM interpreter inside an iOS app, call an async function — and nothing happens. No error, no crash, no result. The task is created, the continuation suspends, and the program waits forever for a scheduler nobody is driving.
We hit this wall building Patch, which ships changed Swift to native iOS apps as interpreted WASM. The WASM-safe slice of a real codebase compiles fine (how we get Swift into WASM at all is its own post) — but real app code is async everywhere, and an OTA system that taps out at the async keyword is a demo. This post is how we made unmodified Swift concurrency — await, async let, task groups, actors — actually execute inside a WASM module, by handing the event loop to the host.
Why does Swift async/await hang in WebAssembly?
In a WASI module there is no event loop, no threads, and nothing driving the scheduler. Swift's runtime enqueues every continuation through swift_task_enqueueGlobal and assumes something will run the queue — on Apple platforms and Linux, libdispatch's cooperative thread pool; on platforms without Dispatch, a built-in cooperative executor. In a WASI reactor nothing ever drains that queue, so the async function never returns: no error, no crash, just silence.
| Platform | Who runs Swift concurrency jobs |
|---|---|
| Apple platforms / Linux | libdispatch's cooperative thread pool |
| Browser WASM (SwiftWasm + JavaScriptKit) | JavaScriptEventLoop hands jobs to the browser's event loop |
| WASI reactor in an embedded interpreter | nobody — you must drive the executor yourself |
What await compiles down to
Swift concurrency is, mechanically, three things: continuations (an async function is split at each await into resumable pieces), jobs (a runnable chunk of work), and executors (the thing that runs jobs). When execution hits an await, the function's state is parked; resuming it means someone enqueues the continuation as a job — via the runtime entry point swift_task_enqueueGlobal — and someone eventually dequeues and runs it. The runtime doesn't run jobs by magic; it assumes a scheduler is attached to the bottom of it.
Our OTA modules are WASI reactors: passive libraries. The host instantiates the module and calls exported functions; between calls, the module simply doesn't execute. No main, no run loop, and in our single-threaded WASM world, no threads to park a pool on. Worse, the one thing a scheduler normally does — block until there's work — is forbidden: the guest shares its only thread with the interpreter inside a UI app.
So: Task { … } runs, the runtime calls swift_task_enqueueGlobal, and the job goes… nowhere. That's the hang.
The fix: swift_task_enqueueGlobal_hook plus a host-driven pump
The Swift runtime exposes an override point for swapping the global executor: swift_task_enqueueGlobal_hook. It's unofficial but long-stable — and it has public prior art: it's the same hook SwiftWasm's JavaScriptEventLoop has used for years to hand Swift concurrency to the browser's event loop. Our problem differs in two ways: our host is a native iOS process pumping a WasmKit reactor on its own schedule, not a browser that owns the loop already — and our continuations have to survive a round-trip across host imports (more below).
Inside the guest, the hook does the least imaginable work — capture, never run:
swift_task_enqueueGlobal_hook = { job, _ in
jobQueue.append(job) // FIFO-capture; never run or block here
}
@_cdecl("patch_pump")
func pump(budget: Int32) -> Int32 {
var ran: Int32 = 0
while ran < budget, let job = jobQueue.popFirst() {
swift_job_run(job, executor)
ran += 1
}
return ran // queue depth signals remaining work
}(Schematic — the shipping version is in the public SDK.)
The host owns the crank, and the contract is explicit: the SDK pumps after every guest entry call and after every bridge resume, draining on its serial queue with a per-drain job budget, until the guest reports an empty queue. Pumping happens only from the top of the stack — pumping while inside a guest call would be recursion into the interpreter. So a call sequence looks like:
Host calls
start_work()— the task is created and its initial job is enqueued (a plainTask {}doesn't run its body synchronously; it lands in the FIFO).Host pumps — the body runs to its first
await; continuations enqueue more jobs; the drain continues within budget.Host keeps pumping until the queue is empty and the work has produced its result.
The concurrency runtime is completely real — real tasks, real continuations, real actors. What changes is the scheduler underneath: cooperative, host-driven, FIFO. A long-running job delays guest progress until it yields, not the host's UI thread — and budget-bounded drains are what keep one greedy job from monopolizing the pump.
Async networking from WASM: bridging await to URLSession on the host
Where this gets satisfying is async work that leaves the sandbox:
let (data, _) = try await URLSession.shared.data(from: url)A WASM module only gets the capabilities its host hands it — ours gets no sockets, and shouldn't: we want TLS, cookies, and credentials to stay native. So the await crosses the bridge:
Guest: our shim parks the continuation under an ID and calls a host import —
http_get(urlPtr, urlLen, id)— which returns immediately. Nothing blocks; the guest unwinds back to the host.Host: kicks off a real
URLSessionrequest — native networking stack, native TLS; the guest never sees a credential. (The flip side — downloaded code talking through your app's network stack — is exactly why modules are hash-verified before they're ever instantiated.)Response arrives: the host writes the bytes into guest memory and calls an exported resume entry with the continuation's ID; the resumed continuation's job lands in the FIFO.
Host pumps. The guest resumes on the line after
await, andJSONDecoderdecodes the response inside the interpreter.
Honest scope note: today we bridge the request/response core of URLSession — data(from:)-style fetch-and-decode; delegate-driven, streaming, and background-session APIs stay native. The first end-to-end run — a live HTTPS fetch to api.github.com, decoded entirely inside the WASM module — is the proof the mechanism holds, and the scheduling suite behind it (await-chains, async let, TaskGroup, actors — eleven distinct scheduling scenarios) runs in the SDK's test suite alongside six hundred other tests.
Limitations: cooperative, single-threaded Swift concurrency
This scheme has real boundaries:
Cooperative and single-threaded. Unmodified source, real runtime — but the scheduler underneath is a FIFO, so task priorities flatten, and there's no parallelism to be had. Actors are trivially data-race-free (one thread) but buy no throughput. CPU-heavy work doesn't belong here — which is fine, because per-frame and compute-heavy code is exactly what the partition keeps native to begin with.
The host owns liveness. Cancellation, timeouts, and resume-exactly-once become host responsibilities. Resume a continuation twice and you've corrupted the task; never resume it and you've leaked it. The bridge bookkeeping is the part that must be boring and correct.
When the patch is the bug. A trap inside the module surfaces to the host as a recoverable error, not a process crash: the SDK walks its fallback chain — current → previous → bundled native code — re-validating each rung by actually instantiating it, and a rollout can be killed server-side. Your users run last-known-good code, not a half-executed patch.
Why go to this much trouble
Because async is where the code is. View models, repositories, API clients — the layers you most want to hot-fix in production are the layers modern Swift writes as async. Measured on a 52-app snapshot of our open-source test corpus, this mechanism is what lets 9,261 async functions — 6.8% of every function in the corpus — compile and execute under the interpreter. That's the difference between patching toy functions and patching the code that actually breaks on Friday afternoons.
And the question every iOS engineer asks next is App Review. Short version: the module is interpreted — WasmKit executes it; nothing is JIT'd, no native code is downloaded — which is the category of downloaded code Apple's developer agreement conditionally permits. Nothing about that is Apple-approved or guaranteed, and you own your app's compliance; we wrote up the full rules, including what got hot-patching banned in 2017 and how this differs.
Patch ships OTA code updates for native Swift iOS apps — patchrelease.com, docs. The on-device runtime, pump included, is public source: github.com/patch-release/patch-swift. Related: WebAssembly host functions in Swift · Compiling Swift to WebAssembly.