4 min readResonate HQJust published

Recursive factorial as a durable workflow in TypeScript

How recursion stays straight-line code when every invocation is its own durable promise.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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-27

The 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-29

The 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 at factorialWorker.ts:27. SDK: resonate-sdk-ts/src/resonate.ts:253-294.
  • resonate.rpc(id, name, ...args, opts) — top-level Remote Function Call. The id (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 at factorialClient.ts:22-29. SDK: resonate-sdk-ts/src/resonate.ts:373-420.
  • ctx.rpc(name, ...args, opts) — in-coroutine Remote Function Call; alias for ctx.rfc. Creates a child durable promise per invocation and yields back the resolved value. Used for the n -> n-1 recursive step at factorialWorker.ts:14-18, with the target: "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. The target string itself is constructed by the SDK as poll://uni@<group>/<pid> (unicast) and poll://any@<group>/<pid> (anycast) at resonate-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 the target value 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 inner factorial(7), factorial(6), … sub-calls before crashing on factorial(2). When the worker (or a peer in factorial-workers) reclaims the task, the coroutine replays — but every yield* 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 at resonate-sdk-ts/src/context.ts:459-488 plus 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 same factorial-${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 5 a second time does not recompute. The server already holds the resolved durable promise factorial-5 and 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 against poll://any@factorial-workers, any peer worker in factorial-workers can 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