4 min readResonate HQJust published

Durable rate-limited batch processing in TypeScript on Resonate

How a for-loop with ctx.sleep between calls becomes a crash-safe per-workflow rate limiter with no external token bucket.

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

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