Building Reliable Onboarding Flows with Vercel Workflows

Jump to: What is Workflow DevKit Β· Use Cases Β· Getting Started Β· Real-World Examples

npm i workflow

Who to Follow & Honorable Mentions:

Introduction

If you've ever built an onboarding flow, you know the pain: users sign up, you want to send them a welcome email... then another email 3 days later... then a push notification on day 7.

The traditional approach?

  • Set up a queue (Redis, Bull, SQS)
  • Configure a scheduler (cron jobs, AWS EventBridge)
  • Handle retries manually
  • Build monitoring and observability
  • Debug why that day-5 email never sent

The Workflow DevKit approach?

await sleep("7 days");
await sendEmail(user.email);

That's it. The function suspends for 7 days without consuming any resources, then resumes exactly where it left off.


What is Workflow DevKit?

Workflow DevKit is Vercel's open-source solution for making async JavaScript durable. It brings reliability-as-code to your application.

Core Concept: Add "use workflow" to any async function, and it becomes durable β€” it can suspend, resume, and maintain state automatically.

export async function sendEmail(email: string) {
  "use step";
  // Call an API to send emails
}

export async function sendPush(userId: string) {
  "use step";
  // Call an API to send push
}
export async function myWorkflow(userId: string) {
  "use workflow";
  
  // This function is now durable
  // If your server crashes, it will resume
  const user = await getUser(userId);
  await sendEmail(user.email);
  
  // Sleep for 7 days without consuming resources
  await sleep("1 days");
  
  // Send push on day 2
  await sendPush(userId);
}

πŸ“ Important Note: All business logic (API calls, database operations, external services) should be wrapped in functions marked with "use step" for automatic retries. Workflows themselves primarily orchestrate β€” they sleep, call step functions, and handle webhooks. Keep the workflow function clean and focused on the sequence.

Key Features

FeatureDescriptionWhy It Matters
DurabilityWorkflows survive server restartsNo lost progress if deployment happens mid-workflow
SleepPause for hours/days without resourcesBuild multi-day flows without cron jobs
StepsAutomatic retries with exponential backoffExternal API failures don't break your workflow

Use Cases

1. Multi-Day Onboarding Sequences

Send timed notifications or emails to new users over days or weeks.

Example: Day 1 welcome β†’ Day 2 feature tour β†’ Day 5 upgrade prompt

2. Churn Retention Campaigns

Detect when a subscription is about to expire and trigger a retention workflow.

Example: Trial ends in 3 days β†’ Send discount offer β†’ If no conversion, send final reminder

3. Webhook-Driven Workflows

Process webhook events (Stripe, RevenueCat) and trigger multi-step workflows.

Example: User subscribes β†’ Send welcome notification β†’ Wait 30 days β†’ Send renewal reminder

4. In-App Purchase Flows

Handle complex purchase states (initial purchase, renewal, cancellation, billing errors).

Example: Purchase β†’ Unlock features β†’ Wait for renewal β†’ If billing error, notify user


Getting Started

Installation

npm i workflow

Project Structure

Create a dedicated /workflows folder in your project to keep workflows organized:

/app
  /api
    /trigger-onboarding
      route.ts          # API route to trigger workflows
  /workflows
    webPushWorkflow.ts  # Onboarding drip campaign
    churnWorkflow.ts    # Churn retention
    purchaseWorkflow.ts # Purchase flows

This keeps your workflow logic separate from your API routes and makes it easy to find and maintain.


Real-World Examples

Example 1: Multi-Day Push Notification Drip Campaign

This workflow sends a series of push notifications over several days, perfect for onboarding new users.

// /workflows/webPushWorkflow.ts
import { sleep } from "workflow";
import { sendPushNotificationToUserId } from "@/server/notifications";
import type { StringValue } from "ms";

type Step = {
  title: string;
  body: string;
  url: string;
  delay?: StringValue;
};

// Define your onboarding sequence
const onboardingDrip: readonly Step[] = [
  {
    title: "Welcome to the app!",
    body: "Let's get you started with your first project",
    url: "myapp://getting-started",
    delay: "1d"
  },
  {
    title: "Pro tip: Try this feature",
    body: "Here's how to get the most out of the app",
    url: "myapp://features",
    delay: "2d"
  },
  {
    title: "You're doing great!",
    body: "Ready to unlock premium features?",
    url: "myapp://upgrade",
    delay: undefined // End of sequence
  }
] as const;

const sendNotification = async (userId: string, step: Step) => {
  "use step";
  await sendPushNotificationToUserId(userId, step);
};

export async function webPushWorkflow(userId: string) {
  "use workflow";
  
  for (const step of onboardingDrip) {
    await sendNotification(userId, step);
    if (step.delay) await sleep(step.delay);
  }
}

Key Points:

  • "use workflow" makes the entire function durable
  • "use step" adds automatic retries to individual operations
  • sleep() pauses without consuming resources
  • If the server crashes on day 2, the workflow resumes where it left off

Example 2: Triggering Workflows from API Routes

Create an API route to start your workflow when a user signs up:

// /app/api/trigger-onboarding/route.ts
import { NextResponse } from "next/server";
import { webPushWorkflow } from "@/workflows/webPushWorkflow";
import { start } from "workflow/api";

export async function POST(request: Request) {
  const { userId } = await request.json();
  
  // Start the workflow in the background
  await start(webPushWorkflow, [userId]);
  
  return NextResponse.json({ message: "Onboarding workflow started" });
}

Usage: Call this API route after a user signs up, and the entire drip campaign runs automatically over the next several days.


Example 3: In-App Purchase Product Tour with RevenueCat

When a user makes their first purchase, guide them through the premium features they just unlocked:

// /workflows/premiumOnboardingWorkflow.ts
import { sleep } from "workflow";
import { sendPushNotificationToUserId } from "@/server/notifications";

const sendTourNotification = async (
  userId: string,
  title: string,
  body: string,
  deepLink: string
) => {
  "use step";
  await sendPushNotificationToUserId(userId, {
    title,
    body,
    url: deepLink
  });
};

export async function premiumOnboardingWorkflow(userId: string) {
  "use workflow";
  
  // Immediate: Welcome and unlock confirmation
  await sendTourNotification(
    userId,
    "πŸŽ‰ Welcome to Premium!",
    "Your premium features are now unlocked. Let's show you around!",
    "myapp://premium/welcome"
  );
  
  // 1 hour later: First premium feature
  await sleep("1h");
  await sendTourNotification(
    userId,
    "✨ Try Advanced Analytics",
    "See insights that free users can't access",
    "myapp://features/analytics"
  );
  
  // 1 day later: Second premium feature
  await sleep("1d");
  await sendTourNotification(
    userId,
    "πŸš€ Unlock Automation",
    "Set up your first automation in 2 minutes",
    "myapp://features/automation"
  );
  
  // 3 days later: Third premium feature
  await sleep("2d");
  await sendTourNotification(
    userId,
    "πŸ’Ž Pro tip: Custom exports",
    "Export your data in any format you need",
    "myapp://features/exports"
  );
}

RevenueCat Webhook Integration:

// /app/api/webhooks/revenuecat/route.ts
import { start } from "workflow/api";
import { premiumOnboardingWorkflow } from "@/workflows/premiumOnboardingWorkflow";

export async function POST(request: Request) {
  // ... verify RevenueCat signature ...
  
  const body = await request.json();
  const userId = body.event?.app_user_id;
  
  switch (body.event.type) {
    case "INITIAL_PURCHASE":
      // User just upgraded - start premium feature tour
      await start(premiumOnboardingWorkflow, [userId]);
      break;
  }
  
  return NextResponse.json({ received: true });
}

Example 4: Win-Back Campaign with Stripe Subscription Cancellation

When a user cancels their subscription, automatically trigger a retention workflow that offers increasing discounts over 5 days:

Workflow:

// /workflows/winBackWorkflow.ts
import { sleep } from "workflow";
import { sendEmail } from "@/server/notifications";

const sendCouponEmail = async (userId: string, discount: number, couponCode: string) => {
  "use step";
  await sendEmail(userId, {
    subject: `We miss you! Here's ${discount}% off to come back`,
    couponCode,
    discount
  });
};

export async function winBackWorkflow(userId: string, userEmail: string) {
  "use workflow";
  
  // Day 1: 20% off
  await sleep("1d");
  await sendCouponEmail(userId, 20, "COMEBACK20");
  
  // Day 3: 35% off
  await sleep("2d");
  await sendCouponEmail(userId, 35, "COMEBACK35");
  
  // Day 5: 50% off (final offer)
  await sleep("2d");
  await sendCouponEmail(userId, 50, "LASTCHANCE50");
}

Stripe Webhook Integration:

// /app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { start } from "workflow/api";
import { winBackWorkflow } from "@/workflows/winBackWorkflow";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = (await headers()).get("stripe-signature")!;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    return NextResponse.json(
      { error: `Webhook Error: ${(err as Error).message}` },
      { status: 400 }
    );
  }

  // Handle subscription cancellation
  if (event.type === "customer.subscription.deleted") {
    const subscription = event.data.object as Stripe.Subscription;
    const userId = subscription.metadata?.userId;
    const userEmail = subscription.metadata?.userEmail;

    if (userId && userEmail) {
      // Start the win-back workflow
      await start(winBackWorkflow, [userId, userEmail]);
      console.log(`πŸ”„ Started win-back workflow for user ${userId}`);
    }
  }

  return NextResponse.json({ received: true });
}

What happens:

  1. User cancels subscription β†’ Stripe sends customer.subscription.deleted webhook
  2. Your webhook handler starts the win-back workflow
  3. Day 1: User gets 20% coupon code
  4. Day 3: User gets 35% coupon code
  5. Day 5: User gets 50% final offer

The workflow runs in the background for 5 days without any additional infrastructure. If the user resubscribes, you can cancel the workflow using the workflow ID.


Advanced Patterns

Environment-Aware Delays

Test your workflows locally without waiting days:

const delays = {
  "1d": process.env.NODE_ENV === "production" ? "1d" : "1m",
  "7d": process.env.NODE_ENV === "production" ? "7d" : "2m",
};

// Use in your workflow
await sleep(delays["7d"]); // 7 days in prod, 2 minutes locally

Error Handling

Steps automatically retry with exponential backoff, but you can handle fatal errors:

import { FatalError } from "workflow";

const sendEmail = async (email: string) => {
  "use step";
  
  const response = await emailService.send(email);
  
  if (response.error === "invalid_email") {
    // Don't retry invalid emails
    throw new FatalError("Invalid email address");
  }
  
  // Other errors will retry automatically
  if (!response.success) {
    throw new Error("Failed to send email");
  }
};

Conditional Workflows

Branch your workflow based on user actions:

export async function smartOnboardingWorkflow(userId: string) {
  "use workflow";
  
  await sendWelcomeEmail(userId);
  await sleep("1d");
  
  // Check if user completed onboarding
  const user = await getUser(userId);
  
  if (!user.hasCompletedOnboarding) {
    // Send reminder
    await sendOnboardingReminder(userId);
    await sleep("2d");
    
    // Check again
    const updatedUser = await getUser(userId);
    if (!updatedUser.hasCompletedOnboarding) {
      // Send help offer
      await sendHelpOffer(userId);
    }
  } else {
    // Send success message
    await sendCongratsEmail(userId);
  }
}

Best Practices

1. Keep Workflows Focused

Each workflow should have a single responsibility:

Good:

webPushWorkflow()      // Handles push notifications
emailDripWorkflow()    // Handles email campaign
churnWorkflow()        // Handles retention

Avoid:

megaOnboardingWorkflow() // Does everything

2. Use Steps for External Calls

Wrap any external API call in a step for automatic retries:

const callExternalAPI = async (data: any) => {
  "use step";
  return await fetch("https://api.example.com", {
    method: "POST",
    body: JSON.stringify(data)
  });
};

3. Store Workflow IDs

Track which workflows are running for each user:

const { id } = await start(onboardingWorkflow, [userId]);

await db.users.update(userId, {
  onboardingWorkflowId: id
});

This lets you cancel or check the status of workflows later.

4. Use JSON Config for Scalable Drip Campaigns

For production, avoid hardcoding campaigns. Use a config file to make them easy to update and A/B test:

// /config/campaigns.ts
export const campaigns = {
  onboarding: {
    steps: [
      { type: "email", template: "welcome", data: { subject: "Welcome!" } },
      { type: "push", delay: "1d", template: "tour", data: { title: "Take a tour" } },
      { type: "email", delay: "3d", template: "tips", data: { subject: "Pro tips" } }
    ]
  },
  winback: {
    steps: [
      { type: "email", delay: "1d", template: "discount_20", data: { coupon: "BACK20" } },
      { type: "email", delay: "3d", template: "discount_50", data: { coupon: "BACK50" } }
    ]
  }
};

// Generic workflow that runs any campaign
export async function dripCampaignWorkflow(userId: string, campaignId: string) {
  "use workflow";
  const campaign = campaigns[campaignId];
  
  for (const step of campaign.steps) {
    if (step.delay) await sleep(step.delay);
    await executeStep(userId, step);
  }
}

Benefits: Add campaigns without code changes, marketing teams can edit, easy A/B testing.


Why I'm Excited About This

I've spent months wrestling with queue systems, cron jobs, and custom retry logic. Every time I wanted to add a simple "send email in 3 days" feature, it meant:

  • Setting up Redis or SQS
  • Writing scheduler code
  • Building monitoring
  • Debugging why jobs occasionally disappeared

With Workflow DevKit, it's just:

await sleep("3d");
await sendEmail();

This is the abstraction I wish I had when building my last three products. It's durable by default, observable out of the box, and doesn't require infrastructure changes.

For bootstrapped developers watching every dollar, removing queue infrastructure while gaining reliability is a win-win.


Getting Started Checklist

  • Install workflow package
  • Create /workflows folder in your project
  • Build your first workflow with "use workflow"
  • Wrap external calls with "use step"
  • Create API route to trigger workflows with start()
  • Test locally (use short delays)
  • Deploy and monitor with built-in observability

Resources

Official Docs:


What I'm Building Next

I'm using Workflow DevKit to power onboarding and retention flows for my next SaaS product. The goal: deliver a best-in-class user experience without burning my budget on infrastructure.

I'll share updates on the patterns I discover and the gotchas I hit along the way.


Just Scratching the Surface

The examples in this guide cover common use cases, but we're barely scratching the surface of what's possible with durable workflows. Here are some inspiring and innovative ideas to spark your imagination:

Scheduled Content Publishing Pipelines

  • Schedule post β†’ Sleep until publish time β†’ Generate images with AI β†’ Optimize for each platform β†’ Post to all channels β†’ Track engagement

Smart Home Automation Flows

  • Detect user leaves home β†’ Sleep 10 minutes β†’ If still away, adjust thermostat β†’ Sleep until bedtime β†’ Prepare for arrival

Multi-Stage Interview Scheduling

  • Candidate applies β†’ Send assessment β†’ Sleep 3 days β†’ Auto-schedule if passed β†’ Send reminders β†’ Collect feedback after each round

Compliance & Audit Workflows

  • User requests data deletion β†’ Sleep 30 days (grace period) β†’ Permanently delete β†’ Generate compliance report β†’ Archive logs

Gaming: Daily Rewards & Challenges

  • User completes challenge β†’ Award points β†’ Sleep until tomorrow β†’ Reset daily quests β†’ Trigger weekly bonus on day 7

The key insight: any process that spans time can be a workflow. If you're currently using cron jobs, queues, or polling loops, you probably have a workflow waiting to be simplified.


Have you tried Workflow DevKit? What workflows are you building? I'd love to hear about your use cases. DMs are open!

Thanks for reading! Stay shipping πŸš€

@jerrickhakim