Async RPC across services in TypeScript on Resonate

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

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

The 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 static id ("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 for rfc. Blocks the generator until the remote function returns, and returns the result inline. Used to chain foo → 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 is detached<T>(func: string, ...args: any[]): RFI<T> (node_modules/@resonatehq/sdk/dist/context.d.ts:89-90); the future is returned but the caller never yield*s on it, so the caller's generator continues immediately. Used to chain qux → 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. zim uses two rfi calls back-to-back, then awaits both futures, producing in-parallel execution of rax and dop. service_g.ts:18, service_g.ts:22.

Application Node identity (group + pid) — each service constructs the Resonate client with a hard-coded group (e.g., "service-a") and a fresh randomUUID() pid. The target: "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.detached call 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 injecting yield* 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 static promiseId (gateway.ts:34, :56, :76), a re-sent cURL request invokes beginRpc with 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/catch is needed in foo, bar, baz, qux, quz, cog, zim, rax, or dop (README.md:82). The only try/catch blocks 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 — qux detaches quz detaches cog, and cog prints the value on service-f (service_f.ts:17). The gateway returns "detached-chain started" immediately after beginRpc returns (gateway.ts:57-63) — the inner ctx.detached from qux to quz happens later, once service-d picks 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-a target 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.rfi to grab futures up front and yield* 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.detached and 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 group on each node and target poll://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