A monitoring workflow needs to run for days, weeks, or months — checking services, firing alerts, sleeping between checks — and survive step-level failures without re-running work it has already finished. On Resonate, the workflow is a generator function that loops while yielding to ctx.run for each health-check step and ctx.sleep between iterations, with each yield producing an independent durable checkpoint. The example shows the same workflow code running in two modes: a happy-path run of 8 iterations, and a crash-mode run where the monitoring step throws mid-iteration and the SDK retries the step in place without re-executing earlier iterations.
The shape of the solution
export function* healthMonitor(
ctx: Context,
config: MonitorConfig,
shouldCrash: boolean,
): Generator<any, MonitorResult, any> {
const alerts: Alert[] = [];
const startTime = Date.now();
let iteration = 0;
// Loop forever (capped at maxIterations for the demo).
// In production, set maxIterations = Infinity.
// No continueAsNew required — Resonate has no history limit.
while (iteration < config.maxIterations) {
iteration++;
// Check all services in this iteration
const crashThisIteration = shouldCrash && iteration === 3;
const result = yield* ctx.run(
checkAllServices,
config.services,
iteration,
crashThisIteration,
);
// Collect any alerts
for (const alert of result.alerts) {
alerts.push(alert);
}
// Sleep between checks (durable — survives crashes)
if (iteration < config.maxIterations) {
yield* ctx.sleep(config.intervalMs);
}
}
return {
iterations: iteration,
alerts,
uptime: Date.now() - startTime,
};
}
// from example-infinite-workflow-ts/src/workflow.ts:37-77The workflow is registered and invoked from the entry point with a string id:
const resonate = new Resonate();
resonate.register(healthMonitor);
// ...
const result = await resonate.run(
`monitor/${Date.now()}`,
healthMonitor,
config,
shouldCrash,
);
// from example-infinite-workflow-ts/src/index.ts:8-9, 37-42The durable primitives in play
resonate.register(healthMonitor)— registers the generator as a workflow the runtime can replay.src/index.ts:9.resonate.run(id, healthMonitor, config, shouldCrash)— starts a durable invocation keyed byid. The example builds the id as`monitor/${Date.now()}`(src/index.ts:38), so a fresh id is minted per process; a stable id would attach to the existing invocation on a subsequent call rather than starting a fresh one, but the demo does not exercise that path.yield* ctx.run(checkAllServices, ...)— runs the health-check step as a durable child. The step's argument list and return value are persisted; if the step throws, the SDK retries it without re-running prior iterations.src/workflow.ts:54-59.yield* ctx.sleep(config.intervalMs)— durable timer. The deadline is persisted by the SDK so it can be honored independently of the calling loop.src/workflow.ts:68.- Generator-local state (
alerts,iteration,startTime) — deterministically reconstructed on replay: the SDK re-executes the generator from the top and substitutes cached results at each prioryield*, so locals end up with the same values they had before.src/workflow.ts:42-44,62-64,72-76.
What the SDK handles vs. what you write
You write a plain TypeScript generator with a while loop, a counter, an alerts array, and two yield points. There is no checkpoint object, no periodic history-reset step, no manual state serialization between iterations, and no retry loop around the health-check step.
The SDK handles the durability mechanics around those yields. Each yield* ctx.run(...) is a checkpoint: its argument list and return value are persisted, so on replay the SDK feeds the cached result back into the generator instead of re-invoking checkAllServices. Each yield* ctx.sleep(ms) is a durable timer: the deadline is persisted, so the SDK can honor it independently of when the calling generator next resumes. When checkAllServices throws, the SDK catches it and re-invokes the step (the runtime line Function 'checkAllServices' failed with '...' (retrying in ...) in README.md:88 comes from the SDK, not user code); iterations that already completed are not re-executed because their results are already in the durable promise store.
The result: the only thing the loop body has to know is that yielding is how it makes progress. Durability is what the SDK adds; the loop body is unchanged.
Failure modes covered
The shipped demo instantiates Resonate in embedded mode (new Resonate() at src/index.ts:8), which keeps the promise store in process memory (README.md:142-145). The bullets below scope each failure mode to what the demo actually exercises versus what the same workflow code does when run against a Resonate server backend, which the example does not stand up.
- The health-check step throws. Demonstrated by the demo.
checkAllServicesthrows on iteration 3 on its first attempt in crash mode (src/workflow.ts:105-108). The SDK catches the throw and re-invokes the step in the same process. The module-levelattemptMap(src/workflow.ts:90,99-100) makes the second invocation take the success branch. Iterations 1 and 2 are not re-checked because theirctx.runresults are already checkpointed. - Alerts must persist across iterations. Demonstrated by the demo. The generator-local
alertsarray is reconstructed on replay because the SDK re-executes the generator with the cachedctx.runresults substituted at each prior yield, soalerts.push(alert)(src/workflow.ts:62-64) produces the same array contents it had before the retry. - The workflow runs longer than any single event-history budget. Demonstrated by the demo's design. There is no per-workflow history-size cap to hit: each yield is an independent durable promise resolution. The loop body never has to "reset" itself with a periodic state-extraction step.
src/workflow.ts:46-49. - Process crashes mid-sleep or between iterations. Not demonstrated by this demo. The shipped example uses embedded mode, so a real process crash drops the in-memory store. Run the same workflow code against a Resonate server (
README.md:142-145) and the sleep deadline plus the per-iterationctx.runresults survive in the server-side promise store; on restart, the SDK replays the generator, fast-forwards through cachedctx.runcalls, and resumes at the next pending yield rather than at the top of the loop. The workflow source does not change between embedded and server modes — only thenew Resonate()instantiation.
When to reach for this pattern
- If you need a workflow that runs for days, weeks, or months and must survive worker crashes without losing its position (paired with a Resonate server backend for cross-restart durability).
- If a periodic check (health monitor, polling integration, scheduled reconciliation) needs durable backoff between iterations rather than
setIntervalin a regular process. - If you would otherwise be reaching for a periodic state-extraction step to reset an event history before it grows past a system-defined cap — on Resonate, that step does not exist; just keep looping.
- If iterations should be independently retried on failure without re-running prior iterations.
- If the workflow accumulates local state (counters, alert lists, last-seen tokens) across iterations and you don't want to externalize that state into a database row just to make it survivable.
Sources
- Example repo: https://github.com/resonatehq-examples/example-infinite-workflow-ts
- Workflow source:
src/workflow.ts(thehealthMonitorgenerator and thecheckAllServicesstep) - Entry point:
src/index.ts(Resonate instantiation,register,run) - SDK pin:
@resonatehq/sdk^0.10.0(package.json:11) - Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
- Resonate docs: https://docs.resonatehq.io
- Related example (the underlying durable-sleep primitive): https://github.com/resonatehq-examples/example-durable-sleep-ts
