The Approval Workflow That Doesn’t Need a Queue
How to pause a function for hours, days, or weeks—and pick up exactly where you left off.
There’s a class of problem that trips up every backend engineer eventually: the workflow that needs human input.
Maybe it’s a refund request that requires manager approval. A document that needs a signature. An AI-generated report that someone needs to review before it goes out. The pattern is always the same: your code runs, hits a point where it needs a human, and then... what?
The traditional answer involves queues, state machines, scheduled jobs checking a database, and a lot of ceremony. You break the workflow into pieces, store intermediate state somewhere, and wire up the pieces with infrastructure.
What if you could just... wait?
| function* approvalWorkflow(ctx: Context, requestId: string) { | |
| // Do some work | |
| const request = yield* ctx.run(fetchRequest, requestId); | |
| // Create a promise and wait for human approval | |
| const approvalPromise = yield* ctx.promise({}); | |
| yield* ctx.run(sendApprovalEmail, approvalPromise.id); | |
| // This line doesn't execute until a human approves | |
| const decision = yield* approvalPromise; | |
| // Continue with the decision | |
| if (decision === "approved") { | |
| yield* ctx.run(processApproval, request); | |
| } | |
| return "Workflow complete"; | |
| } |
That `yield* approvalPromise` line can pause for hours. Days. Weeks. The function suspends execution, and resumes exactly where it left off when the promise resolves.
No queue. No scheduled job. No state machine. Just async/await (well, generators—but same idea).
How It Works
Let’s look at a complete example. We have two components:
A Worker that runs workflows:
| import { Resonate } from "@resonatehq/sdk"; | |
| import type { Context } from "@resonatehq/sdk"; | |
| const resonate = new Resonate({ | |
| url: "http://localhost:8001", | |
| group: "workers", | |
| }); | |
| async function sendEmail(_: Context, promiseId: string) { | |
| const link = "http://localhost:5001/unblock-workflow?promise_id=" + promiseId; | |
| console.log("Email sent! Please resolve the promise by visiting: " + link); | |
| return "Email sent for promise " + promiseId; | |
| } | |
| function* fooWorkflow(ctx: Context, workflowId: string) { | |
| const blockingPromise = yield* ctx.promise({}); | |
| yield* ctx.run(sendEmail, blockingPromise.id); | |
| console.log("workflow blocked, waiting on human interaction"); | |
| // Wait for the promise to be resolved | |
| const data = yield* blockingPromise; | |
| console.log("workflow unblocked, promise resolved with " + data); | |
| return "foo workflow " + workflowId + " complete"; | |
| } | |
| resonate.register("foo-workflow", fooWorkflow); |
A Gateway that starts workflows and handles approvals:
| import express from "express"; | |
| import { Resonate } from "@resonatehq/sdk"; | |
| const app = express(); | |
| const resonate = new Resonate({ | |
| url: "http://localhost:8001", | |
| group: "gateway", | |
| }); | |
| // Start a workflow | |
| app.post("/start-workflow", async (req, res) => { | |
| const workflowId = req.body?.workflow_id; | |
| const result = await resonate.rpc( | |
| workflowId, | |
| "foo-workflow", | |
| workflowId, | |
| resonate.options({ target: "poll://any@workers" }) | |
| ); | |
| return res.json({ message: result }); | |
| }); | |
| // Approve (unblock) a workflow | |
| app.get("/unblock-workflow", async (req, res) => { | |
| const promiseId = req.query.promise_id; | |
| const data = Buffer.from(JSON.stringify("human_approval"), "utf8") | |
| .toString("base64"); | |
| await resonate.promises.resolve(promiseId, { data }); | |
| return res.json({ message: "workflow unblocked" }); | |
| }); | |
| app.listen(5001); |
Here’s what happens:
A request hits `/start-workflow`
The gateway starts `fooWorkflow` on a worker
The workflow creates a blocking promise and sends an approval email
The workflow suspends at `yield* blockingPromise`
Time passes. Could be seconds. Could be days.
Someone clicks the approval link, hitting `/unblock-workflow`
The gateway resolves the promise
The workflow resumes from exactly where it paused
Why This Matters
Traditional approaches require you to think in events: “when approval happens, look up the request, load state, figure out what step we’re on, execute the next step, save state.” You’re building a state machine, whether you call it that or not.
With durable execution, you think in code: “do this, then wait, then do that.” The framework handles the rest. Your code reads like the workflow it represents.
This isn’t magic—Resonate is persisting the execution state. But that’s the framework’s job, not yours. You write the workflow. Resonate makes it durable.
Automatic Recovery
Here’s the part that makes this production-ready: if a worker crashes while waiting for approval, the workflow doesn’t die. Another worker picks it up. The blocking promise is still waiting to be resolved. When someone finally clicks that approval link, a healthy worker resumes execution.
Kill workers. Restart them. Scale them up and down. The workflow doesn’t care. It’s just waiting for that promise to resolve.
Running It Yourself
The full example is at github.com/resonatehq-examples/example-human-in-the-loop-ts.
# Install Resonate
brew install resonatehq/tap/resonate
# Clone and run
git clone https://github.com/resonatehq-examples/example-human-in-the-loop-ts
cd example-human-in-the-loop-ts
bun install
# Terminal 1: Resonate server
resonate dev
# Terminal 2: Worker
bun run worker.ts
# Terminal 3: Gateway
bun run gateway.ts
# Terminal 4: Start a workflow
curl -X POST http://localhost:5001/start-workflow \
-H “Content-Type: application/json” \
-d ‘{”workflow_id”: “hitl-001”}’The worker will print a link. Click it to approve the workflow.
Complex problems. Simple code.
The human-in-the-loop pattern is one of those problems that sounds simple but traditionally requires a lot of infrastructure. With Resonate’s Distributed Async Await, it’s just... code.
What workflows are you building that could use indefinite suspension?


