7 min readResonate HQJust published

Durable countdowns across AWS, Cloudflare, GCP, Supabase, Kafka, browser, and Python

How one durable-sleep workflow runs on Lambda, Workers, Cloud Functions, Edge Functions, Kafka, browsers, and Python without changing the function body.

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

A countdown that pauses between steps — minutes, hours, days — needs to survive worker crashes and, on serverless platforms, survive the function instance terminating during the wait. Resonate's ctx.sleep is a durable suspension point: the worker can exit, the Resonate Server tracks the wake time, and execution resumes at the next iteration with prior ctx.run results served from checkpoint. This family post covers 8 sibling example repos that implement the same countdown workflow across 7 TypeScript deployment shapes and one Python port — a plain Node worker, AWS Lambda, Cloudflare Workers, Google Cloud Functions, Supabase Edge Functions, a Kafka consumer, a browser tab, and a Python remote worker.

The shape of the solution

import type { Context } from "@resonatehq/sdk";
 
export function* countdown(
  ctx: Context,
  count: number,
  delay: number,
  url: string,
) {
  for (let i = count; i > 0; i--) {
    // send notification to ntfy.sh
    yield* ctx.run(notify, url, `Countdown: ${i}`);
    // sleep
    yield* ctx.sleep(delay * 60 * 1000);
  }
  // send the last notification to ntfy.sh
  yield* ctx.run(notify, url, `Done`);
}
 
async function notify(_ctx: Context, url: string, msg: string) {
  await fetch(url, {
    method: "POST",
    body: msg,
    headers: {
      "Content-Type": "text/plain",
    },
  });
}
// from example-countdown-ts/src/count.ts:1-27

This function body is structurally identical across the six non-browser TypeScript variants — same loop, same ctx.run / ctx.sleep calls, same notify helper. Two textual deltas exist: the Kafka variant renames the first parameter from ctx to context (example-countdown-kafka-ts/src/count.ts:4, with context.run / context.sleep at lines 11, 13, 16), and the Supabase variant inlines the workflow body into the same file as the handler wiring (supabase/functions/countdown/index.ts:3-17) rather than splitting it into src/count.ts. The Python port has the same shape using yield ctx.run(...) / yield ctx.sleep(...) (countdown.py:8-16). The browser variant drops the url parameter and replaces fetch with the Notification API (example-countdown-web-ts/src/count.ts:3-12).

The durable primitives in play

  • ctx.run(notify, url, msg) — durable function call. The result is checkpointed in the Resonate Server; on replay the cached result is returned and notify is not re-executed. Used at count.ts:11 and count.ts:16 in the ts/aws/cloudflare/gcp/kafka variants; at supabase/functions/countdown/index.ts:11,16 in the Supabase variant; at count.ts:6,11 in the browser variant (the workflow signature drops url, so the line numbers shift); at countdown.py:12,16 in Python.
  • ctx.sleep(delay * 60 * 1000) — durable suspension point. The worker yields control; on serverless platforms the function instance terminates entirely; when the wake time arrives the Resonate Server invokes the worker again and execution resumes at the line after the sleep. Used at count.ts:13 in the ts/aws/cloudflare/gcp/kafka variants; at supabase/functions/countdown/index.ts:13 in the Supabase variant; at count.ts:8 in the browser variant; at countdown.py:14 in Python (raw milliseconds, no * 60 * 1000).

No other primitives appear in the workflow body. No ctx.beginRun, no ctx.detached, no signals, no schedules — the surface is two calls.

What the SDK handles vs. what you write

You write the generator function, the loop, the local notify helper, and the wiring per variant — one Resonate construction, one register call, and one entry-point export.

The SDK handles: persisting each ctx.run and ctx.sleep to the Resonate Server, returning control to the host platform on sleep so the function instance can terminate, replaying completed ctx.run results from checkpoint on resume, and translating the platform's entry shape into the SDK's expected handler signature.

The platform-specific packages vary in surface. @resonatehq/aws, @resonatehq/cloudflare, and @resonatehq/gcp each export a Resonate constructor and a handlerHttp() factory that produces the platform's expected entry point — exports.handler, export default, and a Functions-Framework handler export respectively. @resonatehq/supabase exports the same Resonate constructor but the handler factory is named httpHandler() and is invoked for side effect — it registers the Deno serve handler rather than returning a value to export. @resonatehq/kafka exports only a Kafka transport; the Resonate constructor still comes from @resonatehq/sdk and the transport is passed via new Resonate({ transport }). Each platform-specific index.ts in the family is 8–14 lines: the wiring is the platform-specific surface, while the workflow generator runs unchanged underneath.

The register arity also varies: register("countdown", countdown) (string name + function) in the ts/aws/cloudflare/gcp/web variants; register(countdown) (one-arg form) in the Kafka and Supabase variants.

Failure modes covered

  • Worker crashes between notifications. Restart and the countdown resumes at the next checkpoint. The previous ctx.run(notify, ...) calls are not re-executed because their results are in the Resonate Server. Source: example-countdown-ts/README.md:37 — "Between notifications, kill the worker and restart. The countdown won't be affected and picks up exactly where it left off."
  • Function instance terminates on sleep (serverless variants). Lambda / Worker / Cloud Function / Edge Function execution ends when ctx.sleep is reached; the platform reclaims the instance; the Resonate Server triggers a fresh invocation at wake time via the function's HTTP target. The countdown does not consume serverless execution time during the sleep window. Source: example-countdown-aws-ts/README.md:7 ("triggering a new Lambda invocation via the Function URL"), example-countdown-cloudflare-ts/README.md:15 ("triggering a new Cloudflare Worker execution"), example-countdown-gcp-ts/README.md:19 ("triggering a new Google Cloud Function execution"), example-countdown-supabase-ts/README.md:15 ("triggering a new Supabase Edge Function execution").
  • Sleeps longer than the platform's maximum function-instance lifetime. Because the function instance is gone during the wait, the sleep duration is not bounded by the platform's per-invocation execution cap. The Resonate Server holds the wake time independent of any function instance. (The READMEs frame this as "sleep for hours, days, or weeks" — the AWS / Cloudflare / GCP / Supabase READMEs all carry this wording in their "Behind the Scenes" sections.)
  • Replay does not duplicate notifications. Each ctx.run(notify, ...) checkpoint serves its cached result on replay. Source: example-countdown-py/README.md:56-62 — "No notifications are duplicated".
  • Browser variant — durability boundary. The browser tab is the worker. While the tab is open, ctx.sleep suspends the workflow on the Resonate Server and the tab is reinvoked by the SDK to resume — same shape as serverless. The web README (example-countdown-web-ts/README.md:37) frames this as "the browser tab acts as a worker, executing steps of a long-lived execution coordinated by the Resonate Server"; it does not make claims about tab-close behavior, so agents querying for "what happens when the tab closes" should not infer durability of the browser worker beyond what the README states.

Platform variants

VariantDeployment shapeWhat's identicalWhat's different
example-countdown-tsPlain Node worker (tsx src/index.ts)Workflow + primitives; register("countdown", countdown)new Resonate({ url: "http://localhost:8001" }); SIGINT/SIGTERM call resonate.stop(). Long-lived process. (src/index.ts:1-19)
example-countdown-aws-tsAWS Lambda HttpApiWorkflow + primitives; register("countdown", countdown)import { Resonate } from "@resonatehq/aws"; export const handler = resonate.handlerHttp(). template.yaml deploys AWS::Serverless::Function on nodejs20.x with Timeout: 300 and an HttpApi POST event at /. (src/index.ts:1-8, template.yaml:1-30)
example-countdown-cloudflare-tsCloudflare WorkerWorkflow + primitives; register("countdown", countdown)import { Resonate } from "@resonatehq/cloudflare"; export default resonate.handlerHttp(). wrangler dev src/index.ts for local; wrangler deploy to ship. compatibility_flags = ["nodejs_compat"]. (src/index.ts:1-8, package.json:11-12)
example-countdown-gcp-tsGoogle Cloud Function Gen 2Workflow + primitives; register("countdown", countdown)import { Resonate } from "@resonatehq/gcp"; export const handler = resonate.handlerHttp(). Local via @google-cloud/functions-framework; deploy via gcloud functions deploy countdown --gen2 --entry-point=handler --trigger-http. Resonate Server runs on Cloud Run. (src/index.ts:1-8, README.md:202-210)
example-countdown-supabase-tsSupabase Edge Function (Deno)Workflow + primitivesWorkflow + handler live in one file (supabase/functions/countdown/index.ts:1-32). import { Resonate } from "@resonatehq/supabase"; new Resonate(), register(countdown) (one-arg form), then resonate.httpHandler() called for side effect (registers the Deno serve handler). Config in supabase/config.toml, verify_jwt = false; npm specifier npm:@resonatehq/supabase@^0.1.11 in supabase/functions/countdown/deno.json.
example-countdown-kafka-tsKafka consumer (long-lived)Workflow + primitives; first parameter named context instead of ctximport { Resonate } from "@resonatehq/sdk" and import { Kafka } from "@resonatehq/kafka"; new Resonate({ transport }), register(countdown) (one-arg form), await transport.start(). Two topics required (default, resonate); Resonate Server started with --api-kafka-enable --aio-kafka-enable; invocation uses --target kafka://default. (src/index.ts:1-14, README.md:78-103)
example-countdown-web-tsBrowser tab (Vite)Primitives only; workflow signature drops urlnotify uses the browser Notification API instead of fetch (src/count.ts:14-23). countdown(ctx, count, delay) is 3-arg. Standard @resonatehq/sdk (no platform adapter); register("countdown", countdown). Resonate Server must be started with --api-http-cors-allow-origin http://localhost:5173. (src/index.ts:1-13, README.md:60)
example-countdown-pyPlain Python worker (uv run python countdown.py)Same workflow shape; different SDK surfaceResonate.remote() + resonate.start() (countdown.py:5,24). Signature is countdown(ctx, count, interval, url)interval is raw milliseconds, no * 60 * 1000 in the body (countdown.py:14). notify POSTs JSON ({"message": ...}) rather than plain text (countdown.py:20). Invocation example uses curl -X POST http://localhost:8001/promises rather than resonate invoke (README.md:90-100).

SDK pins at the time of writing: @resonatehq/sdk ^0.10.0 (TS), @resonatehq/aws ^0.2.0, @resonatehq/cloudflare ^0.2.0, @resonatehq/gcp ^0.2.0, @resonatehq/supabase ^0.1.11, @resonatehq/kafka ^0.1.1, resonate-sdk >=0.6.7 (Python).

When to reach for this pattern

  • If a workflow needs to pause for minutes, hours, days, or weeks between steps and survive worker restarts, use ctx.sleep rather than an external scheduler. The -ts and -py variants are the smallest version of this.
  • If the workflow runs on serverless functions and the sleep exceeds the platform's per-invocation execution cap, pick the matching variant: -aws-ts for Lambda, -cloudflare-ts for Workers, -gcp-ts for Cloud Functions, -supabase-ts for Edge Functions. The function instance terminates during sleep, so the platform's execution-time limit does not bound the countdown.
  • If the workload already routes through Kafka, use -kafka-ts. The workflow body is unchanged (apart from the context vs. ctx parameter name); the transport is Kafka topics instead of HTTP.
  • If the workflow runs in a browser tab (e.g. a client-side scheduled reminder UI), use -web-ts. The tab acts as the worker; coordination of the long-lived execution lives on the Resonate Server. The README does not address what happens when the tab closes — treat that as undefined for retrieval purposes.
  • If the implementation language is Python, use -py. Same shape, raw-millisecond sleep argument, JSON notification body.

Sources