Async HTTP APIs with durable execution

Turn long-running HTTP requests into durable workflows. Clients fire-and-forget, then poll for completion.

Async HTTP API banner

A FastAPI gateway converts every inbound request into a durable workflow, returns a promise ID immediately, and exposes a /wait endpoint clients poll for completion.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: 0.4.0, in active development.

The problem#

Synchronous HTTP either blocks the client until work finishes or punts the polling problem to whoever calls you. Hand-rolled status tracking gets out of sync, server crashes leave clients guessing whether work completed, and retries duplicate work that already ran.

Resonate's solution#

Each inbound request becomes a durable workflow keyed by a client-provided ID. The gateway returns the ID immediately; clients poll a status endpoint that asks Resonate whether the promise has resolved. Crashes don't lose progress — the workflow resumes on a survivor and the same ID still answers the poll.

Code walkthrough#

Two processes: a gateway that accepts HTTP requests and a worker that runs the durable function.

The gateway#

POST /begin enqueues durable work and returns a promise ID. GET /wait polls for completion. The same client-provided ID deduplicates retries — sending /begin twice with the same ID reconnects to the in-flight execution.

gateway.ts
import express from "express";
import { randomUUID } from "node:crypto";
import { Resonate } from "@resonatehq/sdk";

const app = express();
app.use(express.json());

const resonate = new Resonate({ url: "http://localhost:8001", group: "gateway" });

app.post("/begin", async (req, res) => {
  const id = (req.query.id as string) ?? randomUUID();
  const data = req.body && Object.keys(req.body).length > 0 ? req.body : { foo: "bar" };

  // beginRpc returns immediately. Same id reconnects to the in-flight execution.
  const handle = await resonate.beginRpc(
    id,
    "foo",
    data,
    resonate.options({ target: "poll://any@worker" }),
  );

  res.json({ promise: handle.id, status: "pending", wait: `/wait?id=${handle.id}` });
});

app.get("/wait", async (req, res) => {
  const id = req.query.id as string;
  const handle = await resonate.get(id);

  // done() is non-blocking — asks the server for current state.
  if (await handle.done()) {
    const result = await handle.result();
    return res.json({ status: "resolved", promise_id: id, result });
  }
  res.json({ status: "pending", promise_id: id });
});

app.listen(5001, "127.0.0.1");

The worker#

@resonate.register (Python) or resonate.register("foo", foo) (TypeScript) exposes foo as a durable function. The gateway's beginRpc dispatches the call to whichever worker in the worker group claims it first; if that worker dies mid-execution, another picks up the workflow.

worker.ts
import { Resonate, type Context } from "@resonatehq/sdk";

const resonate = new Resonate({ url: "http://localhost:8001", group: "worker" });

// Inputs and return value must be JSON-serializable.
function* foo(_: Context, data: unknown) {
  // Real workloads call ctx.run(stepFn, ...) for checkpointed side effects.
  return { result: `Processed: ${JSON.stringify(data)}`, timestamp: Date.now() };
}

resonate.register("foo", foo);

Run it locally#

You need a Resonate Server, the worker, and the gateway running in separate terminals.

Clone the repo and install:

code
git clone https://github.com/resonatehq-examples/example-async-http-api-ts
cd example-async-http-api-ts
bun install
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminal 2 — worker
bun run worker
Terminal 3 — gateway
bun run gateway

Begin a workflow:

code
curl -X POST "http://localhost:5001/begin?id=job-001" \
  -H "Content-Type: application/json" \
  -d '{"foo": "hello"}'

The response includes a wait URL. Poll it:

code
curl "http://localhost:5001/wait?id=job-001"

You'll see pending until the worker completes, then resolved with the result.

Try the dedup story#

Call /begin twice with the same id. Resonate doesn't run the work twice — the second call reconnects to the in-flight execution and the polling endpoint returns the same eventual result. This is the same idempotency primitive that makes safe retries cheap.

  • Human-in-the-loop — the same gateway+worker shape, with a workflow that suspends on a durable promise.
  • Load balancing — what happens when you run multiple workers in the same group.