4 min readResonate HQJust published

Periodic function scheduling in TypeScript on Resonate

A worked example of `resonate.schedule()` — what the server persists, what the worker claims, and what the function author writes.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

A cron-style periodic job needs to survive worker crashes and process restarts without skipping ticks or replaying side-effects from scratch. Resonate's schedule() API persists a cron expression on the Resonate server, which then fires a fresh durable promise on each tick that any registered worker can claim and execute. The example-schedule-ts repo demonstrates the minimal three-file shape: a one-shot script that creates the schedule, a long-running worker that processes ticks, and the function itself.

The shape of the solution

The schedule is created once. The Resonate server owns the cron timer from that point on.

import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./report";
 
const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register("generateReport", generateReport);
 
async function main() {
  try {
    // Schedule generateReport to run every minute
    await resonate.schedule(
      "daily_report",  // schedule ID
      "* * * * *",     // cron: every minute (change to "0 9 * * *" for daily at 9am)
      generateReport,  // function to run
      123,             // userId argument
    );
    console.log("Schedule created. Start the worker to process executions.");
  } catch (e: any) {
    if (e?.serverError?.code === 40901) {
      console.log("Schedule already exists. Start the worker to process executions.");
    } else {
      throw e;
    }
  } finally {
    resonate.stop();
  }
}
// from example-schedule-ts/src/schedule.ts:5-30

The function itself is ordinary TypeScript. It receives a Context as its first argument but does not need to use it for this single-step body:

import type { Context } from "@resonatehq/sdk";
 
export async function generateReport(ctx: Context, userId: number): Promise<string> {
  const timestamp = new Date().toISOString();
  const report = `[${timestamp}] Report for user ${userId}`;
  console.log(report);
  return report;
}
// from example-schedule-ts/src/report.ts:1-8

The worker is a separate process. It registers the same function under the same name and idles until the server hands it a task:

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...");
 
process.on("SIGINT", () => {
  resonate.stop();
  process.exit(0);
});
 
process.on("SIGTERM", () => {
  resonate.stop();
  process.exit(0);
});
// from example-schedule-ts/src/worker.ts:5-21

The durable primitives in play

  • resonate.schedule(name, cron, func, ...args) — persists a recurring schedule on the Resonate server. Each cron tick creates a new durable promise whose ID encodes the schedule name and tick timestamp, guaranteeing uniqueness across ticks. (src/schedule.ts:14-19. SDK: Resonate.schedule(), resonate.d.ts.)
  • resonate.register(name, func) — registers a function so a worker process can claim tasks the server dispatches under that name. Both the scheduler script and the worker register the same name; only the worker is the long-running task-claimer. (src/schedule.ts:9, src/worker.ts:9.)
  • Context parameter on the scheduled function — every Resonate function receives a Context as its first parameter. This example does not use it (no sub-step ctx.run / ctx.sleep), but the slot is there for adding durable checkpoints inside the function later. (src/report.ts:3.)
  • resonate.stop() — graceful shutdown that releases the network transport, heartbeat loop, and subscription refresh interval. Called from the scheduler's finally block and from the worker's signal handlers. (src/schedule.ts:28, src/worker.ts:14,19.)

What the SDK handles vs. what you write

SDK handlesYou write
Cron parsing and tick firing on the serverThe cron expression as a string
Generating a unique durable promise per tick (<schedule-name>.<timestamp>)The schedule's logical name
Persisting the schedule across server restartsA one-shot creation script
Idempotency of schedule creation — a duplicate name surfaces as serverError.code === 40901The 40901 branch in the catch block, if you want to make the script re-runnable
Dispatching the promise to any worker that has the function registeredA worker process that calls register() and stays alive
Retrying the function if the worker crashes before completingNothing — the SDK + server own this
Network transport, heartbeats, task claims, and subscriptionsA graceful stop() on shutdown so claims are released cleanly

The pattern is: the server owns time, the worker owns execution. The application author owns only the cron string, the function body, and process lifecycle.

Failure modes covered

  • Worker crashes mid-execution. The cron tick already created a durable promise on the server. The task is unclaimed (or reclaimed after the worker's TTL expires) and dispatched again when a worker — the same one after restart, or another instance with the function registered — becomes available. The SDK's resonate.d.ts schedule() docstring documents this directly: "triggers a new durable promise for each cron tick" (node_modules/@resonatehq/sdk/dist/resonate.d.ts:100-102).
  • The scheduler script is run twice. The second run hits a server-side conflict surfaced as ResonateError.serverError.code === 40901. The example catches that specific code and logs a benign message instead of throwing (src/schedule.ts:21-26).
  • Worker is offline when a tick fires. The durable promise is persisted on the server. It waits in the unclaimed state until a worker comes online and claims it.
  • Worker shuts down on SIGINT / SIGTERM. Signal handlers call resonate.stop() and then process.exit(0) (src/worker.ts:13-21). Note: stop() returns Promise<void> (resonate.d.ts:158) but the example does not await it before calling process.exit, so the synchronous exit terminates the process before the returned promise resolves. Shutdown is therefore best-effort — outstanding claims may not be released to the server before exit. If clean release matters, await resonate.stop() before exiting.

What this example does not handle, because the scheduled function is a single-step body with no sub-checkpoints:

  • Partial-progress recovery inside generateReport. If the function had multiple side-effects, they would need ctx.run(...) wrappers to checkpoint individually. As written, a crash mid-generateReport means the entire function re-runs.

When to reach for this pattern

  • If you need a cron-style periodic job whose ticks must not silently disappear when the worker is down or crashes.
  • If you want the cron timer to live on a server, not inside the application process — so deploying a new worker version does not reset or duplicate the schedule.
  • If the work each tick performs is short and idempotent enough that a full re-run on crash is acceptable. (If it isn't, wrap the side-effects inside the function with ctx.run(...) to get per-step durability.)
  • If you want a single schedule definition that any number of workers can process, with the server load-balancing tasks across them.
  • If you want re-running the schedule-creation script to be a no-op rather than an error — catch serverError.code === 40901.

Sources