Durable rate-limited batch processing in TypeScript on Resonate

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:29

Setup 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,43

The 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 at README.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 is rate-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. callExternalApi raises Error("Process crashed at req_005 — resuming from checkpoint") on its first attempt when the id matches crashAtId (src/api.ts:47-53). The SDK's default retry policy catches this and retries that specific ctx.run invocation; the earlier loop iterations are not re-run. The README shows this as (retrying in 2 secs) followed by req_005 ... → ok ... (retry 2) (README.md:97-98).

Earlier API calls are not re-sent on retry. The SDK's per-ctx.run checkpointing means earlier loop iterations short-circuit to their stored results when the workflow replays after req_005 throws; requests 1–4 are not re-invoked. The callAttempts map in src/api.ts:30 is demo machinery — its only job is to gate the simulated throw so it fires on attempt === 1 only (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 of req_005 because the SDK's default retry delay (~2 seconds, per README.md:97,112) is already longer than the 333ms inter-request interval, and the next ctx.sleep(intervalMs) between req_005 and req_006 still 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.sleep semantics 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 uses rate-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.0 in package.json:11; resolved 0.10.0 in bun.lock:18)

ctx.sleep and ctx.run on the Context type from @resonatehq/sdk — imported at src/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