A business process that needs to pause for hours, days, or longer cannot rely on holding a single Node process open — the longer the process lives, the more likely it crashes. Resonate replaces the in-process wait with a server-backed timer promise: the workflow suspends, the worker becomes free, and any worker in the group resumes the workflow when the timer fires. The example-durable-sleep-ts repo shows this in 40 lines split between worker.ts (15) and client.ts (25), pinned to @resonatehq/sdk ^0.10.0.
The shape of the solution
function* sleepingWorkflow(ctx: Context, ms: number) {
yield* ctx.run((ctx: Context) => console.log(`Sleeping for ${ms / 1000} seconds...`))
yield* ctx.sleep(ms);
return `Slept for ${ms / 1000} seconds`;
}
resonate.register("sleepingWorkflow", sleepingWorkflow);
// from example-durable-sleep-ts/worker.ts:9The workflow is a generator function. The pattern is one yield: yield* ctx.sleep(ms). Yielding hands control back to the SDK, which translates the sleep into a durable timer promise and releases the worker until that promise resolves.
The client side dispatches the workflow by id, by function name, to the workers group:
const id = "sleep-workflow-2";
const func = "sleepingWorkflow";
const ms = 5000;
const result = await resonate.rpc(
id,
func,
ms,
resonate.options({
target: "poll://any@workers",
})
);
// from example-durable-sleep-ts/client.ts:9The worker process is registered into the workers group (worker.ts:6), the client into the client group (client.ts:5), and poll://any@workers is the target string that routes the RPC to any process polling the workers group.
The durable primitives in play
ctx.sleep(ms)— creates a durable timer promise on the Resonate Server and yields control back to the SDK; the workflow suspends until the timer resolves.worker.ts:11; SDK signature atsrc/context.ts:224-226(v0.10.0), implementation atsrc/context.ts:538-555(v0.10.0) — computesuntil = clock.now() + ms, returnsnew RFC(id, "sleep", 1, this.sleepCreateOpts({ id, time: until })). The created promise carriesresonate:scope: "global"andresonate:timer: "true"tags (src/context.ts:683-690, v0.10.0).ctx.run(fn)— local (in-process) function call inside the workflow. The execution is local to the worker; the result is checkpointed on the Resonate Server as a promise taggedresonate:scope: "local"(src/context.ts:604, v0.10.0), so on replay the SDK reuses the recorded result and skips re-execution.worker.ts:10;runis thelfc(local function call) alias — interface atsrc/context.ts:208-209(v0.10.0), bind atsrc/context.ts:276(v0.10.0), implementation atsrc/context.ts:391-421(v0.10.0).resonate.register("sleepingWorkflow", fn)— registers the generator under a string name so the client can dispatch it by name.worker.ts:15; SDK atsrc/resonate.ts:253-294(v0.10.0).resonate.rpc(id, func, args, opts)— durable RPC from outside the workflow: creates a root promise keyed onid, routes the dispatch viatargetto the named group, and awaits the resolved result.client.ts:12-19; SDK atsrc/resonate.ts:373-378(v0.10.0) — delegates tobeginRpc(...).result().- Worker groups +
target: "poll://any@workers"—new Resonate({ url, group: "workers" })in the worker (worker.ts:4-7) andgroup: "client"in the client (client.ts:3-6);groupdefaults to"default"(src/resonate.ts:103, v0.10.0); thetargetoption on the RPC selects any process in the named group.
What the SDK handles vs. what you write
You write: the generator body (function*), the ms argument, the promise id on the client side, the function name string, and the group strings ("workers" / "client"). That is the entire surface.
The SDK and the Resonate Server handle: creating the timer promise with the right deadline, suspending the generator on yield* ctx.sleep(ms) so no Node-level timer is held in the worker, persisting the timer across worker restarts, firing the timer at the requested wall-clock time, dispatching the resumed workflow to any available process in the workers group, and returning the resolved value at the client's await resonate.rpc(...). In this client the dispatch is by name string (func = "sleepingWorkflow"), so the matched overload (rpc<T>(id, name, ...args): Promise<T> at src/resonate.ts:374, v0.10.0) returns Promise<unknown> unless the caller passes an explicit generic or a function reference. Between the yield and the resume there is no JavaScript timer, no setTimeout, and no in-memory closure kept alive on the worker.
Failure modes covered
- Worker crashes mid-sleep. The timer promise lives on the Resonate Server, not in the worker process. When the timer fires, the server dispatches the resumed workflow to any process polling the
workersgroup; this is thepoll://any@workerstarget on the client's RPC (client.ts:17) plus the worker's group registration (worker.ts:6). - Long sleep outlives any single process lifetime. Because the wait is a server-side timer, not a
setTimeout, durations of days or years do not depend on a single Node process surviving that long. The README frames this as the original motivation: "a business process may need to sleep for a period much longer than the typical lifetime of a process" (README.md:17-19). - Client retries with the same id. The RPC is keyed on the promise id (
"sleep-workflow-2"inclient.ts:9); a second RPC with the same id reconnects to the existing pending execution rather than starting a new sleep. This is the standard durable-promise dedupe behavior — the id, not the call site, is the identity. - No worker available when the timer fires. The timer still fires on the server; the resumed workflow waits in the queue for the
workersgroup until a process polls for work. Starting any worker registered inworkersis enough to drain it.
When to reach for this pattern
- If a workflow must pause for longer than is comfortable to keep a Node process running (anything from minutes upward, with no upper bound).
- If you would otherwise reach for a cron job, an external delayed-job queue, or a separate timer service purely to re-awaken an idle process — and the only reason for the second system is the duration of the wait.
- If you want the wait to be a single line in the workflow body rather than splitting the workflow into "before the wait" and "after the wait" handlers connected by external state.
- If the resumed work must run on whichever worker is healthy at the moment the timer fires, not necessarily the one that started the wait.
- If you need the wait to be idempotent under retries — a second invocation with the same id during the sleep window must attach to the in-flight timer, not start a second one.
Sources
- Example repo: github.com/resonatehq-examples/example-durable-sleep-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts
- SDK primitives cited (line numbers pinned to v0.10.0, matching the example's
^0.10.0pin):src/context.ts@ v0.10.0 —Context.sleep,Context.run(lfc alias),sleepCreateOpts,localCreateReqsrc/resonate.ts@ v0.10.0 —Resonateconstructor,register,rpc,beginRpc
- Docs:
