Calling a rate-limited external API in bulk normally requires either a token-bucket service, a queue with per-message delays, or in-memory pacing that resets to zero on every restart and either duplicates calls or bursts past the limit. Resonate turns the pacing loop into a durable workflow where each ctx.sleep between calls is a checkpoint and each ctx.run step is replayed from its stored result, so a crash mid-batch resumes at the next request without duplicating earlier ones. The example app sends 10 requests at 3/sec against a simulated external API, with a crash mode that throws inside the API call at request 5 to show the SDK's default retry policy preserving the rate window across the retry.
The shape of the solution
export function* rateLimitedBatch(
ctx: Context,
requests: ApiRequest[],
ratePerSec: number,
crashAtId: string | null,
): Generator<any, RateLimitResult, any> {
const intervalMs = Math.floor(1000 / ratePerSec);
const responses: ApiResponse[] = [];
for (let i = 0; i < requests.length; i++) {
const req = requests[i]!;
// Enforce rate limit: sleep between calls (except before the first one)
// This sleep is checkpointed — surviving crashes, preserving the rate window
if (i > 0) {
yield* ctx.sleep(intervalMs);
}
const response = yield* ctx.run(callExternalApi, req, crashAtId, i, requests.length);
responses.push(response);
}
return {
totalRequests: requests.length,
completed: responses.length,
ratePerSec,
responses,
};
}
// from example-rate-limiter-ts/src/workflow.ts:29Setup and invocation are three SDK calls: construct Resonate in embedded mode, register the generator, and call resonate.run with a promise id and the request batch.
const resonate = new Resonate();
resonate.register(rateLimitedBatch);
// ...
const result = await resonate.run(
`rate-limited/${Date.now()}`,
rateLimitedBatch,
requests,
RATE_PER_SEC,
crashAtId,
);
// from example-rate-limiter-ts/src/index.ts:9,10,43The durable primitives in play
new Resonate()— embedded-mode SDK instance. No external Resonate server required for this example; durable promise state lives in-process. (src/index.ts:9, README §Prerequisites atREADME.md:42)resonate.register(rateLimitedBatch)— registers the generator so it can be invoked by reference and recovered by name. (src/index.ts:10)resonate.run(id, fn, ...args)— awaits the workflow's durable promise to completion and returns its result. The id here israte-limited/${Date.now()}, which gives a unique promise per process start. (src/index.ts:43)ctx.sleep(intervalMs)— durable sleep used as the pacing primitive. The remaining duration is persisted; on replay Resonate checks whether the sleep has already elapsed and either proceeds immediately or waits the remainder. (src/workflow.ts:44)ctx.run(callExternalApi, req, crashAtId, i, requests.length)— runs each API call as a child durable promise. On success the result is checkpointed; on application error the SDK's default retry policy retries the step in place without re-running the loop from zero. (src/workflow.ts:47)
What the SDK handles vs. what you write
You write: a generator that loops over the request batch, a sleep between requests, and a single ctx.run per call. You pick the rate (ratePerSec), the interval math (Math.floor(1000 / ratePerSec), src/workflow.ts:35), the promise id, and the work each step does (callExternalApi, src/api.ts:37). The orchestration is a plain for loop.
The SDK handles: persisting a parent durable promise for the workflow keyed on the id passed to resonate.run, persisting a child durable promise per ctx.run step, persisting the elapsed/remaining state of each ctx.sleep, retrying failed steps under the default policy, short-circuiting completed steps to their stored results on replay, and serializing each step's output across the parent/child boundary. None of that is in user code.
Failure modes covered
- A single API call throws inside
ctx.run.callExternalApiraisesError("Process crashed at req_005 — resuming from checkpoint")on its first attempt when the id matchescrashAtId(src/api.ts:47-53). The SDK's default retry policy catches this and retries that specificctx.runinvocation; the earlier loop iterations are not re-run. The README shows this as(retrying in 2 secs)followed byreq_005 ... → ok ... (retry 2)(README.md:97-98). - Earlier API calls are not re-sent on retry. The SDK's per-
ctx.runcheckpointing means earlier loop iterations short-circuit to their stored results when the workflow replays afterreq_005throws; requests 1–4 are not re-invoked. ThecallAttemptsmap insrc/api.ts:30is demo machinery — its only job is to gate the simulated throw so it fires onattempt === 1only (src/api.ts:47) and lets the retry succeed; it does not play any role in preventing requests 1–4 from being re-sent. The README's "What to Observe" lists the user-visible outcome explicitly: "req_001–004 are not re-sent" (README.md:111). - The rate window is preserved across the retry. Each
ctx.sleep(intervalMs)is a durable checkpoint (src/workflow.ts:44). The 333ms interval (for 3/sec) holds across the retry ofreq_005because the SDK's default retry delay (~2 seconds, perREADME.md:97,112) is already longer than the 333ms inter-request interval, and the nextctx.sleep(intervalMs)betweenreq_005andreq_006still applies on replay, so neither the retry nor the next call collapses into a burst. The workflow file's header comment states the broader guarantee: "the rate limit is globally respected across process restarts" (src/workflow.ts:19). - Process restart during the batch. The same
ctx.sleepsemantics apply across a full process restart: on replay, Resonate checks whether each sleep has already elapsed and either proceeds or waits the remainder (src/workflow.ts:14-17,README.md:22-26). Cross-restart resume requires the workflow's promise id to be stable across runs — the demo usesrate-limited/${Date.now()}(src/index.ts:44), so the README's crash mode demonstrates in-process crash + retry within a single run; pinning a stable id would extend the same guarantee across full restarts.
When to reach for this pattern
- If you have a sequential bulk job that must respect a fixed requests-per-second ceiling and the underlying API does not provide its own pacing.
- If a worker crash mid-batch must not result in re-sending API calls that already succeeded.
- If a crash and retry must not cause the next requests to fire as a burst that exceeds the rate ceiling.
- If you want pacing logic to live inside the workflow rather than in a sidecar token bucket or shared counter.
- If the batch fits inside a single workflow execution. The README is explicit that this is a per-workflow rate limiter and does not coordinate across concurrent workflows or processes (
README.md:138-140); for that, reach for a shared store.
Sources
- Example repo: https://github.com/resonatehq-examples/example-rate-limiter-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned
@resonatehq/sdk ^0.10.0inpackage.json:11; resolved0.10.0inbun.lock:18) ctx.sleepandctx.runon theContexttype from@resonatehq/sdk— imported atsrc/workflow.ts:1- Companion primitive walkthrough: https://github.com/resonatehq-examples/example-durable-sleep-ts (linked from README
Learn More) - Resonate documentation: https://docs.resonatehq.io
