A multi-service request flow that crosses process boundaries needs to survive any node crashing mid-flight without re-doing completed work. Resonate models the entire cross-service call graph as a durable promise rooted at the gateway, so each remote invocation is a checkpoint and any service in the same group can resume an in-flight call. This example exposes three HTTP routes on an Express gateway and routes each to one of three remote-invocation shapes — ctx.rpc (await chain), ctx.detached (fire-and-forget chain), and ctx.rfi (fan-out with futures) — backed by nine TypeScript services.
The shape of the solution
The gateway is the ephemeral-to-durable boundary. Every route uses resonate.beginRpc<T>(promiseId, funcName, arg, options) with a hard-coded promiseId and a target of the form poll://any@<group>:
app.post("/await-chain", async (_req, res) => {
try {
console.log("running await_chain_route_handler");
const promiseId = "await-chain";
const handle = await resonate.beginRpc<number>(
promiseId,
"foo",
"foo",
resonate.options({ target: "poll://any@service-a" }),
);
console.log("waiting on result");
const message = await handle.result();
res.status(200).json({ message });
} catch (e) {
console.error(e);
res.status(500).json({ error: (e as Error).message });
}
});
// from example-async-rpc-ts/src/gateway.ts:31-47Inside the durable call graph, the three flows differ only in which Context method they call. The fan-out workflow is the densest example:
function* zim(ctx: Context, arg: number): Generator<any, number, any> {
console.log("running function zim");
const futureRax = yield* ctx.rfi<number>(
"rax",
ctx.options({ target: "poll://any@service-h" }),
);
const futureDop = yield* ctx.rfi<number>(
"dop",
ctx.options({ target: "poll://any@service-i" }),
);
const resultRax: number = yield* futureRax;
const resultDop: number = yield* futureDop;
return resultRax + resultDop + arg;
}
// from example-async-rpc-ts/src/service_g.ts:16-29The durable primitives in play
resonate.beginRpc<T>(id, func, arg, options)— the ephemeral-to-durable entry. Returns a handle whose.result()blocks on the entire durable call graph. The staticid("await-chain", "detached-chain", "fan-out-workflow") means a re-sent request reconnects to the same in-flight invocation.gateway.ts:35,gateway.ts:57,gateway.ts:77.ctx.rpc<T>(func, options)— syntactic sugar forrfc. Blocks the generator until the remote function returns, and returns the result inline. Used to chainfoo → bar → baz.service_a.ts:17,service_b.ts:17.ctx.detached(func, arg, options)— invokes a remote function without awaiting the returned future or its result. The SDK signature isdetached<T>(func: string, ...args: any[]): RFI<T>(node_modules/@resonatehq/sdk/dist/context.d.ts:89-90); the future is returned but the caller neveryield*s on it, so the caller's generator continues immediately. Used to chainqux → quz → cog.service_d.ts:17,service_e.ts:16.ctx.rfi<T>(func, options)— fully asynchronous remote invocation. Returns a future that can be awaited at any later point in the generator.zimuses tworficalls back-to-back, then awaits both futures, producing in-parallel execution ofraxanddop.service_g.ts:18,service_g.ts:22.- Application Node identity (
group+pid) — each service constructs theResonateclient with a hard-codedgroup(e.g.,"service-a") and a freshrandomUUID()pid. Thetarget: "poll://any@<group>"string routes anycast to the first available node in the group.service_a.ts:4–11,gateway.ts:8–19.
What the SDK handles vs. what you write
You write three things: the Express route handlers that call beginRpc, the generator functions registered with resonate.register(name, fn), and the per-node identity (group, pid). Each generator function is a plain function* that yield*s on a Context method to invoke remote work — there is no transport code, no message broker setup, no per-call retry wrapper, no shared correlation IDs to thread through requests, and no try/catch inside durable functions (README.md:82).
The SDK handles the rest: durably persisting the call graph and per-invocation arguments to the Resonate Server, routing each call to a node in the target group via the poll://any@<group> address, awaiting and resolving cross-process futures, replaying the generator from the last checkpoint after a crash, automatically retrying functions that throw, and reconnecting a re-sent top-level invocation (same promiseId) to the existing in-flight durable promise.
Failure modes covered
- A service node crashes mid-call. Each
ctx.rpc/ctx.rfi/ctx.detachedcall is a durable checkpoint. When a new node joins the same group, it picks up the durable promise and resumes from the last completed step. Documented in README.md:106–119; demonstrable by injectingyield* ctx.sleep(10_000)into any function (README.md:111–117) and killing the process during the sleep. - The gateway crashes after handing off to
beginRpc. The durable call graph continues to make progress in the service groups. Because the route handler uses a staticpromiseId(gateway.ts:34,:56,:76), a re-sent cURL request invokesbeginRpcwith the same id and the SDK returns a handle attached to the existing in-flight promise rather than starting a new one (README.md:119). - A durable function throws. Inside the durable call graph the SDK catches the error and retries the function automatically — no
try/catchis needed infoo,bar,baz,qux,quz,cog,zim,rax, ordop(README.md:82). The onlytry/catchblocks in the codebase are the three route handlers (gateway.ts:32,:54,:74), where the ephemeral HTTP request can't be resumed. - The Express process crashes before printing the result of the detached chain. The detached chain doesn't depend on the gateway for completion —
quxdetachesquzdetachescog, andcogprints the value onservice-f(service_f.ts:17). The gateway returns"detached-chain started"immediately afterbeginRpcreturns (gateway.ts:57-63) — the innerctx.detachedfromquxtoquzhappens later, onceservice-dpicks up the durable promise. The gateway is not in the result path. - Two service-a nodes are running at the same time. The
poll://any@service-atarget is anycast — only one node in the group claims a given invocation. Adding nodes to a group is the horizontal-scaling and high-availability story; killing nodes is the recovery story.
When to reach for this pattern
- If you have an HTTP gateway that fans work out to multiple downstream services and you want crashes anywhere in the chain to be recoverable, not lost.
- If you need a request flow that can be resumed by a re-sent client request (same idempotency key in, same durable promise out).
- If the work fans out in parallel across services and the caller needs to combine results — use
ctx.rfito grab futures up front andyield*each future when you need its result. - If the work is a chain where each step hands off to the next without anyone waiting on the tail — use
ctx.detachedand let the final node print, persist, or notify. - If the work is a synchronous service-to-service chain where each step needs the next step's return value — use
ctx.rpc. - If you are running multiple instances of the same service for HA and want anycast routing without standing up a load balancer, set
groupon each node and targetpoll://any@<group>.
Sources
- Example repo: https://github.com/resonatehq-examples/example-async-rpc-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
- SDK version pinned:
@resonatehq/sdk@^0.10.0(package.json:23). - SDK source —
Context(ctx.rpc,ctx.rfi,ctx.detached): https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.ts - SDK source —
Resonate(resonate.beginRpc,resonate.register,resonate.options): https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/resonate.ts - README — Async RPC explanation: https://github.com/resonatehq-examples/example-async-rpc-ts/blob/main/README.md
- Resonate docs — Async RPC pattern: https://docs.resonatehq.io/get-started/examples/async-rpc
- Resonate docs — TypeScript SDK guide: https://docs.resonatehq.io/develop/typescript
