4 min readResonate HQJust published

Async HTTP API with durable workers in TypeScript on Resonate

How an Express gateway dispatches durable work to a worker pool and lets clients poll a non-blocking status endpoint.

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

An HTTP API that accepts long-running work needs to survive process restarts and worker crashes without losing the request. The shape of the Resonate solution is to split the system in two: a stateless Express gateway that dispatches a durable promise to a separate worker group and returns immediately, plus a polling endpoint that reads completion state from the Resonate Server rather than from gateway memory. The example-async-http-api-ts repo shows this with Express 5 and the TypeScript SDK in roughly 120 lines across two files.

The shape of the solution

const handle = await resonate.beginRpc(
  id,
  "foo",
  data,
  resonate.options({ target: "poll://any@worker" }),
);
 
return res.status(200).json({
  promise: handle.id,
  status: "pending",
  wait: `/wait?id=${handle.id}`,
});
// from example-async-http-api-ts/gateway.ts:25-36

The gateway never awaits the workflow. beginRpc returns a handle as soon as the durable promise is created on the Resonate Server, and the HTTP response goes back to the client with a promise id and a polling URL.

The worker side is a single registered generator function:

function* foo(_: Context, data: unknown) {
  console.log("processing on worker:", data);
 
  // Real workloads would call ctx.run(stepFn, ...) for checkpointed side
  // effects (DB writes, external APIs, long computations). Each ctx.run
  // step is durably recorded so the workflow can resume from the last
  // successful step after a crash or restart.
 
  return {
    result: `Processed: ${JSON.stringify(data)}`,
    timestamp: Date.now(),
  };
}
 
resonate.register("foo", foo);
// from example-async-http-api-ts/worker.ts:12-26

The gateway runs in group "gateway" and the worker in group "worker"; the target: "poll://any@worker" option on beginRpc routes the work to any process in the worker group (gateway.ts:11, worker.ts:6, gateway.ts:29).

The durable primitives in play

  • resonate.beginRpc(id, name, data, options) — creates a durable promise with the supplied id, enqueues the function-name dispatch for the target group, returns a ResonateHandle without awaiting completion. Deduplication is keyed on id: a second call with the same id reattaches rather than starting new work. gateway.ts:25-30.
  • resonate.get<T>(id) — re-attaches to an existing durable promise from a cold start. The /wait handler uses this so the gateway holds no in-memory state between requests. gateway.ts:53.
  • handle.done() — non-blocking check on completion state; returns Promise<boolean> and asks the server for current state. gateway.ts:56; SDK type at dist/resonate.d.ts:11.
  • handle.result() — resolves with the final value or throws the rejection error, so the /wait handler can branch on resolved vs rejected via try/catch. gateway.ts:58; SDK type at dist/resonate.d.ts:10.
  • resonate.register(name, func) — registers a generator function under a name the gateway can dispatch by string. worker.ts:26; SDK type at dist/resonate.d.ts:80.
  • resonate.options({ target }) — builds an options bag that routes the dispatch to a worker group via the poll://any@<group> URL form. gateway.ts:29.

What the SDK handles vs. what you write

You write: the Express routes, the generator function body (foo), the choice of promise id for deduplication, and the group strings ("gateway" / "worker").

The SDK and Resonate Server handle: persisting the durable promise the moment beginRpc returns, routing the dispatch to a process in the worker group, redispatching to another worker if the first one disappears, surfacing completion state to any process that later calls resonate.get(id), and decoding resolved-vs-rejected results through handle.result(). The gateway process never tracks which worker took the job, and the worker process never tracks which gateway requested it — both sides exchange only (id, function_name, data) through the server.

The worker function in this minimal example is a single straight-line generator block — it doesn't yet use ctx.run(...). The inline comment at worker.ts:15-18 calls this out: real workloads would wrap each side-effecting step (DB write, external API call, long computation) in ctx.run(stepFn, ...) so that a crash mid-function resumes from the last successful step rather than from the top. The example demonstrates the dispatch-and-poll shell; per-step checkpointing is the next layer.

Failure modes covered

  • Worker crashes mid-execution. The durable promise lives on the Resonate Server, not in worker memory. When the crashed worker stops heartbeating, the server re-dispatches the work to another process in the worker group. The dispatch target poll://any@worker (gateway.ts:29) is what makes load-balanced redelivery work.
  • Gateway crashes between /begin and the client's first /wait poll. The gateway holds no in-memory map of id to handle. On restart, /wait calls resonate.get(id) (gateway.ts:53) and reads the current state from the server.
  • Client retries /begin with the same id. Resonate deduplicates by id. The second beginRpc call returns a handle to the in-flight (or already-resolved) promise rather than starting duplicate work. The id query parameter on /begin (gateway.ts:18) is what gives clients a stable idempotency key; if it's omitted, the gateway generates a randomUUID() for fresh work (gateway.ts:19).
  • Client polls /wait against a workflow that hasn't completed yet. handle.done() returns false without blocking the gateway thread (gateway.ts:56). The handler responds with {"status":"pending"} and frees the connection.
  • Workflow rejects with an application error. handle.result() throws; the /wait handler catches that and maps it to {"status":"rejected","error":…} instead of crashing the gateway (gateway.ts:64-70).
  • /wait called with an unknown id. resonate.get(id) rejects; the handler returns 404 with {"error":"<id> not found"} (gateway.ts:78-82).

When to reach for this pattern

  • If you're exposing an HTTP endpoint that triggers work taking longer than a reasonable HTTP timeout (seconds to hours) and the client should not hold a connection open.
  • If the same logical request might be sent more than once (network retries, client-side retry loops) and you want the second request to attach to the first rather than duplicate the work.
  • If the HTTP frontend and the work-doing process should scale and crash independently — gateway pods can be replaced without dropping in-flight work, and worker pods can be replaced without dropping in-flight requests.
  • If a status/result endpoint must keep working across full restarts of every process in the system — recovery cannot depend on any specific process being up.
  • If you eventually want each step inside the worker function to be individually checkpointed (DB writes, external API calls, long computations), the same registration shape extends with ctx.run(...) calls inside foo.

Sources