Cron-shaped scheduling with durable executions

Register a function to run on a cron schedule. Each invocation is a durable workflow with crash recovery built in.

Schedule banner

Register a function to run on a cron expression and Resonate handles the rest. Every fired execution is a durable workflow — a worker that crashes mid-run hands off to a survivor, and a missed run because all workers were down still gets dispatched when one comes back.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: v0.1.0, in active development.

The problem#

Cron is fine for "fire a script at a time," but stops there. If the script needs to retry, recover from a crash, dedup against double-fires, or coordinate across multiple machines, you end up wrapping cron in increasingly elaborate scaffolding — usually a queue, a separate worker, and a state table to track which runs completed.

Resonate's solution#

Tell the Resonate Server "run this function on this cron schedule." It dispatches each run as a durable workflow to whichever worker in the group claims it. The worker doesn't care about the schedule — it just runs the function, checkpoints each step, and recovers automatically if anything fails. Schedules persist across server restarts.

Code walkthrough#

Two scripts: a one-shot schedule registration that tells the server about the cron expression, and a worker that executes whatever the schedule fires.

Register the schedule#

src/schedule.ts
import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./report";

const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register("generateReport", generateReport);

await resonate.schedule(
  "daily_report",   // schedule ID — re-running this script is a no-op once it exists
  "* * * * *",      // cron: every minute (use "0 9 * * *" for daily 9am)
  generateReport,
  123,              // userId argument
);
console.log("Schedule created. Start the worker to process executions.");
resonate.stop();

The schedule lives on the server, not in the worker. You can run the registration script once and the schedule keeps firing across worker restarts and redeploys. Re-running the script with the same ID is a no-op.

Run the worker#

src/worker.ts
import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./report";

const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register("generateReport", generateReport);
console.log("Worker started. Waiting for scheduled executions...");

The worker is identical to any other Resonate worker — it doesn't know it's being driven by a schedule. The same function can be invoked by hand via resonate invoke or scheduled cron-style; both paths are durable executions.

Run it locally#

code
git clone https://github.com/resonatehq-examples/example-schedule-ts
cd example-schedule-ts
npm install
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminal 2 — register the schedule (one-shot)
npx tsx src/schedule.ts
Terminal 3 — worker
npx tsx src/worker.ts

The worker prints a log line every minute as the schedule fires. Kill it for a few minutes and restart — runs that fired while it was down are dispatched as soon as it reconnects.

Try the catch-up story#

Stop the worker for a few minutes. Schedule fires keep accumulating on the server. When you restart the worker, the queued runs dispatch in order — none are lost. Now bring up two workers in the same group and watch fires distribute across them.

  • Load balancing — same group dispatch, focused on parallel work.
  • Durable sleep — the in-workflow analogue of cron, for delays inside a single execution.