3 min readResonate HQJust published

Durable onboarding workflow on Supabase Edge Functions in TypeScript

How a stateless Edge Function runs a stateful, checkpointed onboarding workflow keyed by user id.

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

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

The 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-171

The 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 in supabase/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.handler callbacks or another webhook), the generator resumes at the next un-checkpointed yield* ctx.run. The architecture diagram in README.md:60-78 and the comment block at supabase/functions/flows/index.ts:16-25 describe this explicitly.
  • The welcome-email step throws. Only sendWelcomeEmail is retried; validateUser's checkpointed result is reused. The crash demo exercises this path: sendWelcomeEmail throws 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 second resonate.run finds the existing promise and returns its cached result rather than triggering a duplicate onboarding run. The inline comment at supabase/functions/flows/index.ts:139 calls this out.
  • A downstream step has already run on a previous invocation. ctx.run returns 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/sdk in local-demo/src/index.ts:92-110, with a --crash flag that exercises step-level retry behavior without deploying anything.

Sources