A note on the programming-language-theory framing of Temporal — for a reader who writes Python/Go/Java daily but has not had to think about delimited continuations or effect handlers as language features.
In a PL-theory context an effect is anything a function does besides return a value: read or write a variable, throw an exception, do I/O, sleep, log, fork. A pure function (input → output, no effect) is the easy case. Languages add effects in different ways: Java has unchecked I/O and checked exceptions; Python has implicit I/O and raise; Go has panic/recover and channels. Effects are how programs actually do anything useful.
The interesting question for PL designers is: can you make effects first-class — values that the language can pass around, intercept, replace? Most mainstream languages do this for one effect (exceptions, via try/catch) and not for the rest.
Algebraic effects are a generalization of try/catch. Sketch:
// hypothetical syntax
effect Activity { startActivity(name: string, input: bytes): bytes }
handle workflow():
| startActivity(name, input) -> {
let result = ... dispatch to worker, wait for response ...
resume result // <-- the part exceptions can't do
}
The handler intercepts the startActivity call, does whatever it wants (including waiting forever, asking three workers in parallel, returning a fake answer), and then resumes the function from the point where it called startActivity. Exceptions can interrupt; algebraic effects can interrupt and continue.
That extra power — "resume the caller" — is the whole game. It's why algebraic effects can express async/await (resume after the I/O completes), generators (resume to ask for the next value), nondeterminism (resume the caller multiple times with different values), and durable execution (resume across crashes).
Languages with first-class algebraic effects: Koka (Daan Leijen, MSR), Eff, OCaml 5, some research dialects of Haskell/Scala. Mainstream languages don't have them, but they have one of the special cases — exceptions for interruption, async/await for resumption — and not the general primitive.
The mechanism that lets a handler "resume the caller" is a continuation. A continuation is a first-class value representing "the rest of the computation from this point onward". If you have access to the continuation, you can call it whenever you want, with whatever value you want, to resume execution.
- Undelimited continuations (Scheme's
call/cc): the continuation is "the entire rest of the program after this point". Powerful, hard to reason about. - Delimited continuations (
shift/reset, used in Racket and Scala research dialects): the continuation is bounded by an explicit marker. You capture only "the rest of the program up to the nearest enclosingreset". Much more practical. - One-shot vs multi-shot: can the captured continuation be invoked once, or many times? One-shot is enough for most things — exceptions, async, generators. Multi-shot is needed for backtracking search, time-travel, replay.
Algebraic effects are usually implemented with delimited continuations under the hood — when a handler runs, the language captures the continuation up to the handle, hands it to the handler, and the handler decides whether and how to resume.
A coroutine is a function that can pause itself and be resumed later. Python generators (yield), Python async def, Lua coroutines, Kotlin coroutines, Go goroutines (sort of — they're closer to threads), Java Loom virtual threads (under the hood, delimited continuations with a single resumption point) are all variations.
- Asymmetric coroutines: yields always go back to the caller. Python generators are the canonical example. Calling a generator runs it until
yield, then control returns; callingnext()on it resumes. - Symmetric coroutines: a coroutine can yield directly to any other coroutine, not just its caller.
A useful technical observation: an asymmetric one-shot coroutine and a one-shot delimited continuation are the same primitive in different syntactic clothes. A Python generator is, semantically, a one-shot delimited continuation.
A workflow function looks like ordinary procedural code:
@workflow.defn
class Order:
@workflow.run
async def run(self, order: Order) -> Receipt:
validated = await workflow.execute_activity(validate, order, ...)
await asyncio.sleep(60)
receipt = await workflow.execute_activity(charge, validated, ...)
return receiptBut it doesn't run like ordinary code. It runs in a sandboxed worker process; every time it awaits on something Temporal-y, the worker pauses the function, sends a description of what was awaited to the server (a command: "schedule activity validate, run a timer for 60s, schedule activity charge"), and then unloads the function. When the server has the answer (the activity finished, the timer fired), the worker reloads the workflow and runs it again from the beginning, replaying the history of past results to skip over the awaits that have already resolved, until the function reaches a new await and the cycle repeats.
That is a coroutine that pauses on each Temporal await and resumes when the server has the answer. Equivalently, it is a function that issues effects (Activity, Timer, Signal, Update); the server is the effect handler, deciding what to do with each one, and resuming the function with the result.
Three ways the framing pays off:
-
Replay-based determinism = persistent continuations. A normal coroutine pauses by suspending its stack frame in memory. A Temporal workflow pauses by throwing away its stack frame and storing only the history of effects-and-results, then reconstructing the frame from scratch on the next worker turn. The continuation is durable because it's not a stack snapshot — it's the deterministic function plus the history that determines it. Multi-shot, because the history can be replayed any number of times against the same workflow code.
-
Versioning and patching = handler choice. When the workflow asks "what version am I?" via
getVersion, the runtime decides what to answer. On the first execution, it answers with the current version; on a replay months later, it answers with whatever it answered originally. The handler has authority over what the function observes — exactly the algebraic-effects move. This is how Temporal lets you fix bugs in long-running workflows: the handler can inject different responses on replay than it did on the live run. -
Effect-handler powers retrofitted to languages that don't have them. Python doesn't have algebraic effects. Go doesn't. Java doesn't. But Temporal's runtime is an effect handler, and the SDK API is the effect protocol. Your Python code, by virtue of using Temporal, gains a system-level effect handler it couldn't have had as a pure language feature.
A workflow's only "effects" are calls into the SDK. You can't do user-defined effects, can't define new handlers, can't compose multiple handlers. So Temporal is one specific algebraic effect given the durable-handler treatment, not a general algebraic-effects substrate.
The more sober technical statement is: Temporal workflows are durable, multi-shot delimited continuations. "Durable" because the captured continuation survives crashes; "multi-shot" because replay re-invokes the same continuation many times against the recorded history; "delimited" because the bounding marker is the workflow function itself.
Both framings describe the same machine. Algebraic effects are the higher-level explanation of why the model is interesting; durable delimited continuations are the lower-level explanation of what's actually there.
A Temporal workflow is a coroutine whose runtime is also its effect handler, and the runtime makes the continuation durable.