4 min readResonate HQJust published

Async RPC across services in TypeScript on Resonate

Three remote-invocation shapes — rpc, detached, rfi — across an Express gateway and nine service groups, with crash recovery.

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

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