Durable sleep that survives restarts

A workflow that sleeps for seconds, days, or months — without holding a process open or rolling your own scheduler.

Durable Sleep banner

A workflow yields ctx.sleep and the runtime suspends it. Hours, days, or weeks later the worker wakes the workflow up exactly where it stopped — no cron, no scheduler, no external state.

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#

A real business process may need to wait for hours, days, or months — a 30-day trial expiry, a 24-hour billing cycle, a 7-day reminder. Holding a process open that long is fragile: machines reboot, deployments cycle, network blips kill connections.

The conventional escape is a cron job that wakes a separate worker which queries a database to figure out what to do next. That's three moving parts (cron, worker, state row) per workflow, and every one of them is a place for bugs to live.

Resonate's solution#

Yield a sleep from inside a workflow and the runtime checkpoints the wake-up time as a durable promise. The worker doesn't sit idle — it can pick up other work, restart, or get redeployed. When the wake-up time arrives, Resonate dispatches the workflow back to a worker and execution continues from the line after the sleep.

Code walkthrough#

Two processes: a worker that hosts the durable function and a client that dispatches it via async RPC.

The sleeping workflow#

The whole pattern is one line: yield* ctx.sleep(ms). Everything around it is just framing.

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

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

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);

Dispatching the workflow#

The client uses async RPC to dispatch by ID. Reusing the same ID reconnects to a pending execution rather than starting a new sleep.

client.ts
import { Resonate } from "@resonatehq/sdk";

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

const result = await resonate.rpc(
  "sleep-workflow-1",
  "sleepingWorkflow",
  5000,
  resonate.options({ target: "poll://any@workers" }),
);
console.log(result);
resonate.stop();

Run it locally#

You need the Resonate Server, the worker, and a client to dispatch.

Clone the repo:

code
git clone https://github.com/resonatehq-examples/example-durable-sleep-ts
cd example-durable-sleep-ts
npm install
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminal 2 — worker
npx tsx worker.ts
Terminal 3 — client
npx tsx client.ts

After ~5 seconds the client prints Slept for 5 seconds. Now kill the worker mid-sleep and restart — the workflow resumes and still completes its sleep.

Try the long-sleep story#

Change the sleep duration to a day, week, or month. The workflow sleeps without holding a process open — kill the worker, redeploy your code, restart your laptop. When the wake-up time arrives, Resonate dispatches the workflow to whichever worker is alive.