Durable sleep that survives restarts
A workflow that sleeps for seconds, days, or months — without holding a process open or rolling your own scheduler.
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.
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.
A worker that sleeps for N milliseconds, dispatched via async RPC.
A worker that sleeps for N seconds, dispatched via async RPC.
A worker that sleeps for N seconds via ctx.sleep(Duration), dispatched via async RPC.
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.
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);from resonate import Resonate, Context
from threading import Event
resonate = Resonate.remote(group="worker")
@resonate.register
def sleeping_workflow(ctx: Context, wf_id: str, secs: float):
print(f"Workflow {wf_id} starting, will sleep for {secs} seconds.")
yield ctx.sleep(secs)
return f"Workflow {wf_id} completed after sleeping for {secs} seconds."
resonate.start()
print("worker is running...")
Event().wait()use resonate::prelude::*;
use std::time::Duration;
#[resonate::function]
async fn sleeping_workflow(ctx: &Context, secs: u64) -> Result<String> {
println!("sleeping for {secs} seconds...");
ctx.sleep(Duration::from_secs(secs)).await?;
Ok(format!("slept for {secs} seconds"))
}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.
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();from resonate import Resonate
resonate = Resonate.remote(group="client")
handle = resonate.options(target="poll://any@worker").begin_rpc(
"sleep-workflow-1", func="sleeping_workflow", wf_id="sleep-workflow-1", secs=5.0,
)
print(handle.result())use resonate::prelude::*;
#[tokio::main]
async fn main() {
let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
..Default::default()
});
let result: String = resonate
.rpc("sleep-workflow-1", "sleeping_workflow", 5u64)
.target("poll://any@workers")
.await
.expect("rpc to worker failed");
println!("{result}");
}Run it locally#
You need the Resonate Server, the worker, and a client to dispatch.
Clone the repo:
git clone https://github.com/resonatehq-examples/example-durable-sleep-ts
cd example-durable-sleep-ts
npm installbrew install resonatehq/tap/resonate
resonate devnpx tsx worker.tsnpx tsx client.tsAfter ~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.
Clone the repo (uses uv):
git clone https://github.com/resonatehq-examples/example-durable-sleep-py
cd example-durable-sleep-py
uv syncbrew install resonatehq/tap/resonate
resonate serveuv run python worker.pyuv run python client.pyAfter ~5 seconds the client prints the completion message. Kill the worker mid-sleep and restart — the workflow resumes and still completes.
git clone https://github.com/resonatehq-examples/example-durable-sleep-rs
cd example-durable-sleep-rs
cargo buildbrew install resonatehq/tap/resonate
resonate devcargo run --bin workercargo run --bin clientAfter ~5 seconds the client prints the completion message. Kill the worker mid-sleep and restart — the workflow resumes and still completes.
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.
Related#
- Human-in-the-loop — same suspension primitive, but resolved by an external signal instead of a timer.
- Async HTTP API endpoints — kicking off long-running work without blocking the caller.