Consider a function that has stopped in the middle.
async def onboard(user_id):
profile = await fetch_profile(user_id)
approved = await run_checks(profile) # <- stopped here, 40 seconds in
await provision(user_id, approved)
Right now, in the process where this is running, there is an object holding three facts: that
profile has a value, that approved does not yet, and that execution resumes at the second
await. That is a small amount of information: a user id, a profile, and a position.
Which raises the question the whole idea rests on: if that is all the state there is, why does the resume have to happen here?
What the model actually is
A distributed coroutine is not a new language construct. It is an ordinary coroutine plus a rule about where its suspended state lives: instead of only in process memory, it also goes to a store that outlives the process. Any worker that can read the store can pick the coroutine up and continue it.
The consequences are pleasant and worth being concrete about. The 40-second check does not hold a worker; the frame is written down and the worker is released. If the host dies during the check, nothing is lost, because the frame was already durable. A deploy in the middle of the process is survivable, because the resume happens on whichever version of the worker is running when the answer comes back, which is also, immediately, a versioning problem; see below.
Which languages can express it
The requirement is that suspension be a value, not a stack.
Python qualifies, and the mechanics are worth a piece of their own:
a suspended coroutine is a heap object with an inspectable frame; the locals have
names and the instruction offset is readable. Lua qualifies for the same reason, and has since 5.0.
Kotlin qualifies more elegantly than either: the compiler transforms a suspend function into a
state machine with an explicit Continuation object, which is precisely the description a runtime
would otherwise have to reconstruct by hand.
Go does not, and the reason is instructive. A goroutine’s state lives on a real, growable stack with real pointers into it. There is no language-level description of “where this goroutine is” that could be written to a database and read back on another machine with a different address space. Go programs get durability through the other route: split the process into steps, let the framework record each one, and accept the activity/workflow split as the price. Both routes reach the same place; only one of them lets the code look like a plain function.
JavaScript sits awkwardly in the middle. Generators are inspectable enough to build on, async
functions are not, and the ecosystem’s answer has generally been to compile to a generator-based
state machine, the same move Kotlin’s compiler makes, done by a bundler.
The parts that stay hard
Versioning. If a coroutine suspends against version 41 of the code and resumes against version 42, the resume is pointing into a bytecode sequence that may no longer mean what it meant. This is the sharpest edge in the whole model. The workable answers are all forms of pinning: record the code identity with the suspension and refuse to resume it on a different one, then keep old workers alive until their suspensions drain. That is operationally heavier than it sounds, and it is why long suspensions and frequent deploys are in direct tension.
Resource handles. Anything a coroutine is holding that only exists in this process — a socket, a transaction, a file, a lock — cannot cross. So the discipline is: hold nothing across a suspension you intend to migrate. Acquire after, not before. It sounds obvious and it is easy to violate in a helper function three layers down that opens a connection for convenience.
Exactly-once resume. Two workers reading the same suspended state will both continue it. The fix is a lease or a compare-and-swap on the claim, which is the same fix every job queue already has, which means a distributed coroutine runtime inherits every operational concern a queue has plus its own.
An unresolved bit: what to do about observability
There is no settled answer to what a stack trace should look like for a coroutine that has moved three times. The frames are real but they were not all produced on the same machine, the timestamps come from different clocks, and the “call stack” is partly a history of migrations rather than a history of calls. Trace context propagation helps and does not solve it: a span tree describes calls between services, and this is one logical execution wearing several hosts. Several projects have taken a run at it. None of the answers has become the obvious one, and this piece is not going to pretend otherwise.
When it is the right shape
The model fits a specific profile: a process that waits far more than it computes, whose waits are long, whose state at each wait is small, and which must not be restarted from the beginning. Human approval steps. External provider callbacks. Multi-day onboarding. Anything where the honest description is “mostly waiting”.
It does not fit compute. A coroutine that yields constantly pays the durable write constantly, and the write is the expensive part. It also does not fit processes carrying large state, for the reason that keeps recurring in this subject: moving a frame is cheap exactly and only while the frame is small.
Forty seconds of waiting on a background check is close to the ideal case. A thousand yields a second is close to the worst one, and the interesting thing is that the code for both looks identical.