Recursive factorial across worker instances

A workflow that calls itself with `ctx.rpc` — recursion is automatically distributed and durable.

Recursive Factorial banner

A workflow that computes n! by calling itself with n-1. Each recursive call dispatches via ctx.rpc to whichever worker in the group claims it — recursion ends up distributed across machines without any explicit coordination, and every step is checkpointed so a worker crash mid-recursion resumes cleanly.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: v0.1.0, in active development.

The problem#

Naive recursion runs in a single process. If the recursion is deep, you blow the stack. If the recursion is heavy (each call does real work), you can't parallelize across machines without writing your own dispatch layer. Add crashes to the picture and you also need to remember which branches finished and which need to retry.

Resonate's solution#

Use ctx.rpc instead of a direct function call. The recursive call goes to the Resonate Server, which dispatches it to whichever worker in the target group is available — so a deep recursion fans out across N workers automatically. Each call's result is a durable promise, so a worker crash mid-recursion just hands the unfinished call to a survivor.

Code walkthrough#

The pattern is one self-recursive function plus a client that kicks it off.

The recursive workflow#

factorialWorker.ts
import { Resonate, type Context } from "@resonatehq/sdk";

const resonate = new Resonate({
  url: "http://localhost:8001",
  group: "factorial-workers",
});

function* factorial(ctx: Context, n: number): Generator<any, number, any> {
  if (n <= 1) return 1;

  // ctx.rpc dispatches to any worker in the group — this includes the worker
  // running this very call, but might also be a different machine.
  const result = yield* ctx.rpc(
    "factorial",
    n - 1,
    ctx.options({ target: "poll://any@factorial-workers" }),
  );
  return n * result;
}

resonate.register("factorial", factorial);

Kicking it off#

The client looks identical to any other RPC call — Resonate doesn't care that the workflow happens to recurse internally.

factorialClient.ts
import { Resonate } from "@resonatehq/sdk";

const resonate = new Resonate({
  url: "http://localhost:8001",
  group: "factorial-client",
});

const n = Number(process.argv[2] ?? 5);
const result = await resonate.rpc(
  `factorial-${n}`,
  "factorial",
  n,
  resonate.options({ target: "poll://any@factorial-workers" }),
);
console.log(`Factorial of ${n} is ${result}`);
resonate.stop();

Run it locally#

Start the server, run a few workers, then dispatch from the client.

code
git clone https://github.com/resonatehq-examples/example-recursive-factorial-ts
cd example-recursive-factorial-ts
npm install
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminals 2–4 — three workers
npx tsx factorialWorker.ts
Terminal 5 — client
npx tsx factorialClient.ts 10

Watch the three worker terminals — factorial(10), factorial(9), factorial(8) … fan out across the workers as recursion descends.

Try the recovery story#

While the recursion is in flight (start with n=20 to give it some depth), kill one of the workers. Resonate reassigns its in-flight factorial(k) to a survivor. Already-completed sub-results stay completed; only the unfinished branch resumes. The final answer is unchanged.

  • Hello world — the same primitives without the recursion.
  • Load balancing — focused on the dispatch layer that makes the fan-out work.