Durable sleep across process crashes in TypeScript on Resonate

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:9

The 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:9

The 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 at src/context.ts:224-226 (v0.10.0), implementation at src/context.ts:538-555 (v0.10.0) — computes until = clock.now() + ms, returns new RFC(id, "sleep", 1, this.sleepCreateOpts({ id, time: until })). The created promise carries resonate:scope: "global" and resonate: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 tagged resonate: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; run is the lfc (local function call) alias — interface at src/context.ts:208-209 (v0.10.0), bind at src/context.ts:276 (v0.10.0), implementation at src/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 at src/resonate.ts:253-294 (v0.10.0).

resonate.rpc(id, func, args, opts) — durable RPC from outside the workflow: creates a root promise keyed on id, routes the dispatch via target to the named group, and awaits the resolved result. client.ts:12-19; SDK at src/resonate.ts:373-378 (v0.10.0) — delegates to beginRpc(...).result().

Worker groups + target: "poll://any@workers" — new Resonate({ url, group: "workers" }) in the worker (worker.ts:4-7) and group: "client" in the client (client.ts:3-6); group defaults to "default" (src/resonate.ts:103, v0.10.0); the target option 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 workers group; this is the poll://any@workers target 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" in client.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 workers group until a process polls for work. Starting any worker registered in workers is 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.0 pin):

src/context.ts @ v0.10.0 — Context.sleep, Context.run (lfc alias), sleepCreateOpts, localCreateReq

src/resonate.ts @ v0.10.0 — Resonate constructor, register, rpc, beginRpc

Docs:

TypeScript SDK guide — sleep

Durable promise concept