Sleep, Scheduling & Timed Workflows

Workflow's sleep() is durable: it survives cold starts, restarts, and deployments. Combined with defineHook() and Promise.race(), it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.

Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see Versioning for the explicit deploymentId: "latest" continuation pattern.

When to use this

  • Sending emails on a schedule, such as drip campaigns, onboarding sequences, or reminders
  • Waiting for a deadline while allowing early cancellation
  • Reliable, interruptible patterns that perform one action, wait for a specified time, and then perform another action

Drip campaign with cancellation

A drip campaign sends emails at intervals, sleeping between each message. Each sleep races against a cancellation hook. If an external event fires the hook, such as when a user converts or unsubscribes, the campaign stops immediately.

import { defineHook, sleep } from "workflow";

// Hook that any API route can fire to cancel the drip
export const cancelDrip = defineHook<{ reason?: string }>();

async function sendEmail(email: string, template: string): Promise<void> {
  "use step";
  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
    body: JSON.stringify({ to: [{ email }], template_id: template }),
  });
}

export async function emailSequence(email: string) {
  "use workflow";

  await sendEmail(email, "welcome");

  // Race durable sleep against the cancellation hook
  const hook = cancelDrip.create({ token: `cancel-drip:${email}` });
  const cancelled = await Promise.race([ 
    sleep("2d").then(() => false),
    hook.then(() => true),
  ]);
  if (cancelled) return { status: "cancelled", email };

  await sendEmail(email, "getting-started-tips");

  // Create a fresh hook for the next sleep window
  const hook2 = cancelDrip.create({ token: `cancel-drip:${email}` });
  const cancelled2 = await Promise.race([ 
    sleep("2d").then(() => false),
    hook2.then(() => true),
  ]);
  if (cancelled2) return { status: "cancelled", email };

  await sendEmail(email, "feature-highlights");

  return { status: "drip-complete", email };
}

Cancelling from an API route

Any server-side code can fire the hook by calling .resume() with the same token:

import { cancelDrip } from "@/workflows/email-sequence";

export async function POST(req: Request) {
  const { email, reason } = await req.json();

  if (!email) {
    return Response.json({ error: "email is required" }, { status: 400 });
  }

  try {
    await cancelDrip.resume(`cancel-drip:${email}`, {
      reason: reason ?? "User completed action",
    });
  } catch (error) {
    const msg = error instanceof Error ? error.message.toLowerCase() : "";
    if (msg.includes("not found") || msg.includes("expired")) {
      return Response.json({
        success: true,
        email,
        note: "No active drip found (already completed or cancelled)",
      });
    }
    throw error;
  }

  return Response.json({ success: true, email });
}

How it works

  1. Durable sleep: sleep("2d") persists through restarts at zero compute cost. The workflow resumes when the timer fires.
  2. Hook creation: cancelDrip.create({ token }) registers a hook that resolves when any external system calls .resume() with the same token.
  3. Race: Promise.race([sleep(...), hook]) blocks until either the timer fires or the hook is resumed, whichever comes first.
  4. Fresh hooks per window: After a sleep completes normally, the previous hook instance is consumed. A new .create() call registers a fresh hook for the next sleep window, reusing the same token.

Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with await hook.getConflict(), which resolves with the active owner so the duplicate can defer to it. See Idempotency.

Adapting to your use case

  • Change durations: Replace "2d" with any duration string ("1h", "7d", or "30m") or a Date object for absolute times.
  • Add more steps: The pattern scales to any number of email-then-sleep pairs.
  • Snooze instead of cancel: Resolve the hook with a snooze payload and sleep again with sleep(new Date(Date.now() + payload.snoozeMs)).
  • Set a timeout for any operation: Use the same Promise.race(sleep, work) pattern to add deadlines to slow steps.
  • Use production providers: Replace the sendEmail step body with Resend, Postmark, or any HTTP API. The "use step" function has full Node.js access.

Tips

  • Pass supported values to sleep(): Use duration strings ("1d", "2h", or "30s"), milliseconds, or Date objects for sleeping until a specific time.
  • Sleeping consumes no compute or memory: A workflow waiting on sleep("7d") consumes no compute or memory while sleeping.
  • Use sleep() only in workflow context: Step functions cannot call sleep() directly. If a step needs a delay, use setTimeout inside the step.

Key APIs

  • "use workflow": Marks the orchestrator function.
  • "use step": Marks functions that run with full Node.js access.
  • sleep(): Provides a durable wait that survives restarts with zero compute cost.
  • defineHook(): Creates a typed hook that external systems can fire.
  • Promise.race(): Races sleep against hooks for interruptible waits.