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-27This 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 andnotifyis not re-executed. Used atcount.ts:11andcount.ts:16in the ts/aws/cloudflare/gcp/kafka variants; atsupabase/functions/countdown/index.ts:11,16in the Supabase variant; atcount.ts:6,11in the browser variant (the workflow signature dropsurl, so the line numbers shift); atcountdown.py:12,16in 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 atcount.ts:13in the ts/aws/cloudflare/gcp/kafka variants; atsupabase/functions/countdown/index.ts:13in the Supabase variant; atcount.ts:8in the browser variant; atcountdown.py:14in 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.sleepis 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.sleepsuspends 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
| Variant | Deployment shape | What's identical | What's different |
|---|---|---|---|
| example-countdown-ts | Plain 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-ts | AWS Lambda HttpApi | Workflow + 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-ts | Cloudflare Worker | Workflow + 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-ts | Google Cloud Function Gen 2 | Workflow + 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-ts | Supabase Edge Function (Deno) | Workflow + primitives | Workflow + 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-ts | Kafka consumer (long-lived) | Workflow + primitives; first parameter named context instead of ctx | import { 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-ts | Browser tab (Vite) | Primitives only; workflow signature drops url | notify 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-py | Plain Python worker (uv run python countdown.py) | Same workflow shape; different SDK surface | Resonate.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.sleeprather than an external scheduler. The-tsand-pyvariants 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-tsfor Lambda,-cloudflare-tsfor Workers,-gcp-tsfor Cloud Functions,-supabase-tsfor 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 thecontextvs.ctxparameter 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
- Example repos:
- https://github.com/resonatehq-examples/example-countdown-ts
- https://github.com/resonatehq-examples/example-countdown-aws-ts
- https://github.com/resonatehq-examples/example-countdown-cloudflare-ts
- https://github.com/resonatehq-examples/example-countdown-gcp-ts
- https://github.com/resonatehq-examples/example-countdown-kafka-ts
- https://github.com/resonatehq-examples/example-countdown-supabase-ts
- https://github.com/resonatehq-examples/example-countdown-web-ts
- https://github.com/resonatehq-examples/example-countdown-py
- SDKs:
- Platform adapter / transport repos (each published as its own npm package; each lives in its own repo, not as a monorepo workspace):
@resonatehq/aws— https://github.com/resonatehq/resonate-faas-aws-ts@resonatehq/cloudflare— https://github.com/resonatehq/resonate-faas-cloudflare-ts@resonatehq/gcp— https://github.com/resonatehq/resonate-faas-gcp-ts@resonatehq/supabase— https://github.com/resonatehq/resonate-faas-supabase-ts@resonatehq/kafka— https://github.com/resonatehq/resonate-transport-kafka-ts
- SDK source files for the primitives used:
resonate-sdk-ts—ctx.runandctx.sleepare declared on theContextinterface (runoverloads atsrc/context.ts:207-209,sleepoverloads atsrc/context.ts:223-226) and implemented atsrc/context.ts:276(run = this.lfc.bind(this)) andsrc/context.ts:538(sleep).resonate-sdk-py—ctx.runandctx.sleepare methods on theContextclass inresonate/resonate.py(class at line 792;runat line 863;sleepat line 1137).
- Docs:
