A new-user signup needs four steps to run in order — validate, welcome email, provision trial, sync CRM — but the runtime running them is a Supabase Edge Function: stateless, time-bounded, and re-invoked by a webhook that may fire twice for the same row. Resonate makes each step a durable checkpoint stored on the Resonate Server, so the workflow survives function timeouts, step-level failures, and duplicate webhook deliveries keyed by user.id. The example shows a single Edge Function that both registers the workflow and serves as the webhook entrypoint, plus a Node-based local demo that runs the same generator under @resonatehq/sdk to exercise the crash/retry path.
The shape of the solution
function* onboardUser(
ctx: Context,
user: UserRecord,
): Generator<any, OnboardingResult, any> {
// Each step is checkpointed. If the edge function times out between steps,
// the next invocation resumes from where it left off.
yield* ctx.run(validateUser, user);
yield* ctx.run(sendWelcomeEmail, user);
const workspaceId = yield* ctx.run(provisionTrial, user);
yield* ctx.run(notifyCRM, user, workspaceId);
return {
userId: user.id,
email: user.email,
emailSent: true,
trialProvisioned: true,
crmUpdated: true,
completedAt: new Date().toISOString(),
};
}
// from example-supabase-edge-ts/supabase/functions/flows/index.ts:95The webhook handler is the interface boundary between Supabase and Resonate:
const resonate = new Resonate();
resonate.register("onboardUser", onboardUser);
// ...
Deno.serve(async (req: Request) => {
try {
const payload = await req.json();
// Handle Database Webhook trigger
if (payload.type === "INSERT" && payload.table === "users") {
const user = payload.record as UserRecord;
const promiseId = `onboard/${user.id}`;
console.log(`[webhook] New user signup: ${user.email} (${user.id})`);
// Start (or resume) the onboarding workflow.
// If the webhook fires twice for the same user, the second call
// finds the existing promise and returns the cached result.
const result = await resonate.run(promiseId, onboardUser, user);
return new Response(JSON.stringify({ status: "ok", result }), {
headers: { "Content-Type": "application/json" },
});
}
// Let Resonate handle internal execution callbacks
return resonate.handler(req);
} catch (err) {
// ...
}
});
// from example-supabase-edge-ts/supabase/functions/flows/index.ts:124-171The durable primitives in play
yield* ctx.run(fn, ...args)— durable function call. The result of each step is written to the Resonate Server before the workflow advances. Used four times insupabase/functions/flows/index.ts:101-107.resonate.register("onboardUser", onboardUser)— registers the generator so the Resonate Server can drive it across separate Edge Function invocations.supabase/functions/flows/index.ts:125.resonate.run(promiseId, onboardUser, user)— start-or-resume by promise id. The id is the idempotency key: a second call with the same id resolves against the existing promise instead of starting a new run.supabase/functions/flows/index.ts:155.resonate.handler(req)— fallthrough handler for Resonate Server callbacks. The same HTTP entrypoint accepts both the Supabase webhook and the Resonate Server's own continuation pokes.supabase/functions/flows/index.ts:163.
What the SDK handles vs. what you write
You write four plain async (or sync) step functions and a generator that yields each step in order. You write the webhook decoding and the choice of promise id (onboard/${user.id}). You construct new Resonate(); the client reads RESONATE_URL from the environment (supabase/functions/flows/index.ts:122-124).
The SDK and Resonate Server handle: persisting each step result before advancing, resuming the generator from the last checkpoint on the next Edge Function invocation, retrying a failed ctx.run step without re-running upstream checkpointed steps, resolving a second resonate.run call with the same promise id against the existing promise rather than starting a new run, and accepting the server's callback pokes via resonate.handler so the workflow makes forward progress across stateless function invocations.
Failure modes covered
- The Edge Function times out between steps. The Resonate Server holds the last checkpoint. When the next invocation arrives (driven by
resonate.handlercallbacks or another webhook), the generator resumes at the next un-checkpointedyield* ctx.run. The architecture diagram inREADME.md:60-78and the comment block atsupabase/functions/flows/index.ts:16-25describe this explicitly. - The welcome-email step throws. Only
sendWelcomeEmailis retried;validateUser's checkpointed result is reused. The crash demo exercises this path:sendWelcomeEmailthrows on attempt 1 (local-demo/src/index.ts:56-59) and Resonate retries the step. - The webhook fires twice for the same user. Because the promise id is
onboard/${user.id}(supabase/functions/flows/index.ts:148), the secondresonate.runfinds the existing promise and returns its cached result rather than triggering a duplicate onboarding run. The inline comment atsupabase/functions/flows/index.ts:139calls this out. - A downstream step has already run on a previous invocation.
ctx.runreturns the checkpointed value without re-executing the step body, so side-effecting calls (email send, trial provision, CRM sync) are not duplicated on resume.
When to reach for this pattern
- If you're processing Supabase Database Webhooks that may arrive twice for the same row and each downstream step has user-visible side effects (email, CRM, billing).
- If a single signup or onboarding flow has more than two sequential steps that each call out to a separate provider, and you don't want to chain webhooks or stitch together queue jobs to keep them ordered.
- If the work fits comfortably in an Edge Function except for the cases where a step can hang past the function's time budget — and you want resume rather than restart.
- If you want the workflow code, the webhook handler, and the Resonate registration to live in the same file deployed as one Edge Function, with state held off-process on the Resonate Server.
- If you also want a local development loop: the same generator runs under
@resonatehq/sdkinlocal-demo/src/index.ts:92-110, with a--crashflag that exercises step-level retry behavior without deploying anything.
Sources
- Example repo: https://github.com/resonatehq-examples/example-supabase-edge-ts
- Resonate TS SDK (used by the local demo and underpinning the Supabase shim): https://github.com/resonatehq/resonate-sdk-ts
- Supabase shim package pinned by the Edge Function:
@resonatehq/supabase@^0.1.11(supabase/functions/flows/deno.json:3) - Resonate Server install / run instructions referenced by the README: https://docs.resonatehq.io/server/install
- Resonate Server promise-state endpoint queried by
probe:GET /promises/{id}— seesupabase/functions/probe/index.ts:31
