A recursive function that survives worker crashes and reuses previously computed top-level results normally requires a workflow runtime with a separate event history, plus an explicit cache layer keyed by input. Resonate addresses both with a single mechanism: the durable promise. Every function invocation creates a durable promise on the server, identified by an id the caller can supply or let the SDK derive, and that promise is a write-once register that holds the result. This example registers a single recursive factorial generator on a worker group, dispatches the initial call by the id factorial-${n} from a separate client, and lets the SDK handle the recursion across worker processes.
The shape of the solution
// imports + `new Resonate({...})` setup omitted; see factorialWorker.ts:1-7
function* factorial(ctx: Context, n: number): Generator<any, number, any> {
console.log(`Calculating factorial(${n})`);
if (n <= 1) {
return 1;
}
const result = yield* ctx.rpc(
"factorial",
n - 1,
ctx.options({ target: "poll://any@factorial-workers" }),
);
assert(
typeof result === "number",
`Expected result to be a number, got ${typeof result}`,
);
return n * result;
}
resonate.register("factorial", factorial);
// from example-recursive-factorial-ts/factorialWorker.ts:9-27The client kicks the workflow off with a stable top-level id:
// inside main()'s try block; outer 2-space indent stripped for clarity
const result = await resonate.rpc(
`factorial-${n}`,
"factorial",
n,
resonate.options({
target: "poll://any@factorial-workers",
})
);
// from example-recursive-factorial-ts/factorialClient.ts:22-29The durable primitives in play
resonate.register("factorial", factorial)— registers the generator under a name so any worker in the group can dispatch a task to it. Located atfactorialWorker.ts:27. SDK:resonate-sdk-ts/src/resonate.ts:253-294.resonate.rpc(id, name, ...args, opts)— top-level Remote Function Call. Theid(factorial-${n}) is the durable promise id. If a promise with that id already exists and is resolved, the SDK returns the stored value without re-running the function. Located atfactorialClient.ts:22-29. SDK:resonate-sdk-ts/src/resonate.ts:373-420.ctx.rpc(name, ...args, opts)— in-coroutine Remote Function Call; alias forctx.rfc. Creates a child durable promise per invocation and yields back the resolved value. Used for then -> n-1recursive step atfactorialWorker.ts:14-18, with thetarget: "poll://any@factorial-workers"option routing the call to any worker in the group. SDK:resonate-sdk-ts/src/context.ts:215-217, 277, 459-488. Thetargetstring itself is constructed by the SDK aspoll://uni@<group>/<pid>(unicast) andpoll://any@<group>/<pid>(anycast) atresonate-sdk-ts/src/network/http.ts:276-277;ctx.options({...})is a plain builder that carries no durability guarantee of its own — the RPC path reads thetargetvalue to pick the route.- Durable promise as write-once register — every invocation, top-level or recursive, gets its own durable promise on the server. Once resolved, the value is permanent for that id. The durable promise is what each primitive above maps to on the server.
What the SDK handles vs. what you write
You write a plain generator that returns n * (recursive call). There is no event-history bookkeeping in your code, no manual replay logic, no separate "step" / "activity" wrappers, no cache layer.
The SDK handles task dispatch across workers in the group, durable-promise creation per invocation, replay of the coroutine from the last completed sub-call when a worker crashes and re-polls, and the lookup-by-id short-circuit when the top-level factorial-${n} promise already exists in a resolved state. The only durable-execution vocabulary that leaks into your code is ctx.rpc and the target option.
Failure modes covered
- Worker crashes mid-recursion. A worker calculating
factorial(8)has already resolved durable promises for the innerfactorial(7),factorial(6), … sub-calls before crashing onfactorial(2). When the worker (or a peer infactorial-workers) reclaims the task, the coroutine replays — but everyyield* ctx.rpc("factorial", n - 1, ...)whose child durable promise is already resolved returns the stored value without re-executing the function body. Handled by the RFC path atresonate-sdk-ts/src/context.ts:459-488plus server-side durable-promise semantics. - Client crashes before the result is delivered. The client awaits
resonate.rpc(`factorial-${n}`, ...). If the client process dies, restarting it and calling with the samefactorial-${n}id either attaches to the in-flight promise or returns the resolved value (resonate-sdk-ts/src/resonate.ts:380-420). - Repeat top-level invocations. Running
bun run factorialClient.ts 5a second time does not recompute. The server already holds the resolved durable promisefactorial-5and returns its value immediately. This is the caching behaviour the README describes — and it is keyed strictly on the top-level id you pass. - A worker in the group going down does not block recursion. Because every recursive
ctx.rpc(...)is dispatched againstpoll://any@factorial-workers, any peer worker infactorial-workerscan claim the unresolved task. None of this routing is in your code; the SDK polls and claims tasks.
One nuance to note: the recursive sub-calls inside the worker do not pass an explicit id to ctx.rpc, so the SDK derives a sequence-based child id (seqid() → ${parent.id}.${seq}) at resonate-sdk-ts/src/context.ts:474-476, 707-709. Concretely, a top-level invocation of factorial-5 produces child ids like factorial-5.0, then factorial-5.0.0 one level deeper, and so on. The sub-calls are still durable promises and still survive crashes via replay — but they are not addressable by factorial-(n-1) across separate top-level invocations. Cross-invocation result reuse happens at the top-level id only. If you want intermediate values cacheable across separate workflow runs, you pass them explicit ids.
When to reach for this pattern
- If you have a function that calls itself and you need each sub-call to survive a process crash without manual checkpointing.
- If you want repeat top-level invocations keyed by input to return the stored result without writing a cache layer.
- If you want to spread work across a pool of workers and have the runtime handle task claim / replay rather than coordinating queues yourself.
- If you have a tree-shaped or linear computation where the natural code is straight-line recursion and you would rather not flatten it into an event-history-driven workflow.
- If you need addressable, durable per-invocation results that other parts of the system can fetch by id later.
Sources
- Example repo:
resonatehq-examples/example-recursive-factorial-ts - SDK repo:
resonatehq/resonate-sdk-ts(v0.10.0) - SDK source —
ctx.rpc/ctx.rfc:src/context.tslines 215-217, 277, 459-488 - SDK source — top-level
resonate.rpc:src/resonate.tslines 373-420 - SDK source — anycast
poll://any@<group>target:src/network/http.tslines 276-277 - Resonate docs: How Durable Promises Work, TypeScript SDK Guide
