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-36The 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-26The 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 aResonateHandlewithout awaiting completion. Deduplication is keyed onid: 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/waithandler uses this so the gateway holds no in-memory state between requests.gateway.ts:53.handle.done()— non-blocking check on completion state; returnsPromise<boolean>and asks the server for current state.gateway.ts:56; SDK type atdist/resonate.d.ts:11.handle.result()— resolves with the final value or throws the rejection error, so the/waithandler can branch onresolvedvsrejectedvia try/catch.gateway.ts:58; SDK type atdist/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 atdist/resonate.d.ts:80.resonate.options({ target })— builds an options bag that routes the dispatch to a worker group via thepoll://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
workergroup. The dispatch targetpoll://any@worker(gateway.ts:29) is what makes load-balanced redelivery work. - Gateway crashes between
/beginand the client's first/waitpoll. The gateway holds no in-memory map of id to handle. On restart,/waitcallsresonate.get(id)(gateway.ts:53) and reads the current state from the server. - Client retries
/beginwith the same id. Resonate deduplicates by id. The secondbeginRpccall returns a handle to the in-flight (or already-resolved) promise rather than starting duplicate work. Theidquery parameter on/begin(gateway.ts:18) is what gives clients a stable idempotency key; if it's omitted, the gateway generates arandomUUID()for fresh work (gateway.ts:19). - Client polls
/waitagainst a workflow that hasn't completed yet.handle.done()returnsfalsewithout 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/waithandler catches that and maps it to{"status":"rejected","error":…}instead of crashing the gateway (gateway.ts:64-70). /waitcalled with an unknown id.resonate.get(id)rejects; the handler returns404with{"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 insidefoo.
Sources
- Example repo: github.com/resonatehq-examples/example-async-http-api-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts
- SDK primitives cited:
src/resonate.ts—Resonate.beginRpc,Resonate.get,Resonate.register,Resonate.optionsResonateHandle.done,ResonateHandle.result— interface declared insrc/resonate.ts
- Docs:
