Answers you can trust, from Codeables

Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.

Explore Codeables
Verified Source
Durable Workflow Orchestration

How do I implement a multi-step workflow in Inngest using step.run and step.sleep?

Inngest10 min read

Most teams that ask how to wire up multi-step workflows with step.run() and step.sleep() are really asking for something deeper: “How do I make sure each step runs once, retries safely, and doesn’t fall over when something is slow?” That’s exactly what Inngest’s Steps and Durable Execution are designed to handle.

In this guide, I’ll walk through how to implement a multi-step workflow in Inngest using step.run() and step.sleep(), why those primitives matter, and how to avoid the usual worker/queue/cron mess.


What step.run() and step.sleep() Actually Do

Before diving into code, it helps to understand the mechanics. These two methods are the core build­ing blocks for durable, multi-step workflows in Inngest.

step.run() — Durable work, one named unit at a time

step.run(name, fn) wraps a unit of work so that:

  • The step is named and shows up as its own node in Traces.
  • Inngest automatically retries on failure (with configurable policies).
  • On success, the result is checkpointed; if the workflow is retried or replayed, the step is not re-run.
  • Inputs and outputs are logged and inspectable at step granularity.

Mechanism → outcome:

  • Mechanism: Code-level transaction with retry + checkpoint.
  • Outcome: You resume from the last successful step, not from the beginning of the workflow.

step.sleep() — Durable delays instead of cron hacks

step.sleep(duration) lets you pause a workflow without holding any compute:

  • The function effectively “parks” at that point.
  • Inngest schedules the resume; no workers sit idle, no long-lived lambdas.
  • When the sleep completes, the function resumes from the next line of code.

Mechanism → outcome:

  • Mechanism: Durable pause with persisted state.
  • Outcome: You can build multi-day or multi-week flows (e.g., drip campaigns, trial reminders) without extra cron jobs or bespoke schedulers.

Basic Setup: Create an Inngest Function with Steps

Assuming a TypeScript setup, you usually start with something like:

import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "my-app" });

Then define a function that uses Steps:

export const multiStepWorkflow = inngest.createFunction(
  { id: "multi-step-workflow" },
  { event: "app/user.signup" },
  async ({ event, step }) => {
    // Step 1: Validate input
    const user = await step.run("validate-user", async () => {
      if (!event.data.email) {
        throw new Error("Missing email");
      }
      return {
        email: event.data.email,
        name: event.data.name ?? "friend",
      };
    });

    // Step 2: Call external API
    const profile = await step.run("fetch-user-profile", async () => {
      const res = await fetch("https://api.example.com/profile", {
        method: "POST",
        body: JSON.stringify({ email: user.email }),
      });

      if (!res.ok) {
        throw new Error(`Profile API failed: ${res.status}`);
      }

      return res.json();
    });

    // Step 3: Sleep before sending a follow-up
    await step.sleep("wait-before-follow-up", "24h");

    // Step 4: Send follow-up email
    await step.run("send-follow-up-email", async () => {
      // Replace with your email provider
      await sendEmail({
        to: user.email,
        subject: "How’s your experience so far?",
        body: `Hi ${user.name}, how are things going?`,
      });
    });

    return { status: "ok", user, profile };
  }
);

Key things happening here:

  • Each step.run() has a human-readable name (shows up in Traces).
  • Failures inside step.run() are retried automatically; on success, they’re not re-run on replay or subsequent retries.
  • step.sleep() expresses the wait as part of the workflow—the engine handles parking and resuming.

Designing a Multi-Step Workflow with step.run()

The main design rule of thumb: break your workflow into meaningful, inspectable steps that map to business actions or failure domains.

1. Group business logic into named steps

Think of each step.run() as a code-level transaction boundary:

await step.run("create-db-record", async () => {
  // Insert or update in your DB
});

await step.run("enqueue-analytics", async () => {
  // Fire-and-forget analytics call
});

Why this helps:

  • If enqueue-analytics fails but create-db-record succeeds, you can see exactly which step needs attention.
  • On replay, Inngest skips create-db-record (because it succeeded previously) and only re-runs the failed step.

2. Make steps idempotent where side effects matter

Even with checkpointing, your step implementation should be safe to run more than once in edge cases (e.g., after manual retries, partial external failures).

Examples:

  • Use idempotency keys when talking to payment providers.
  • Check if a record already exists before inserting:
await step.run("ensure-invite-created", async () => {
  const existing = await db.invites.findFirst({ where: { userId } });
  if (existing) return existing;

  return db.invites.create({ data: { userId, email } });
});

Checkpointing + idempotent behavior means you avoid duplicate side effects even under retries.

3. Use Steps to isolate failure domains

Any place an error can occur independently should be its own step.run():

  • External API call
  • Database write
  • Sending an email or push notification
  • Long-running computation

This gives you:

  • Fine-grained retries (you don’t retry the whole workflow when one external call is flaky).
  • Clear traceability: which step failed, with which inputs/outputs, at what time.

Using step.sleep() for Delays and Schedules

step.sleep() makes “wait and continue later” a first-class part of your workflow.

Basic usage

In TypeScript:

await step.sleep("wait-24-hours", "24h");

The first argument is a name; the second is the duration (e.g., "5m", "1h", "24h"). When your function hits that line:

  • Inngest stores the current state.
  • No workers or containers are held open.
  • After the delay, Inngest re-invokes the function and resumes from after the sleep call.

Example: Multi-email drip with sleeps

A classic example (and straight out of Inngest’s “email sequence” use case) is a drip campaign:

export const onboardingDrip = inngest.createFunction(
  { id: "onboarding-drip" },
  { event: "app/user.signup" },
  async ({ event, step }) => {
    const user = event.data;

    // Email 1: Welcome immediately
    await step.run("send-welcome-email", async () => {
      await sendEmail({
        to: user.email,
        subject: "Welcome aboard",
        body: "Here’s how to get started…",
      });
    });

    // Wait 3 days
    await step.sleep("wait-3-days", "72h");

    // Email 2: Feature highlight
    await step.run("send-feature-email", async () => {
      await sendEmail({
        to: user.email,
        subject: "Unlock more value",
        body: "Did you know you can also…",
      });
    });

    // Wait 7 more days
    await step.sleep("wait-7-days", "168h");

    // Email 3: “Are you stuck?” check-in
    await step.run("send-checkin-email", async () => {
      await sendEmail({
        to: user.email,
        subject: "Need a hand?",
        body: "We noticed you haven’t fully onboarded yet…",
      });
    });
  }
);

Things you don’t need with this approach:

  • No cron jobs to schedule each follow-up.
  • No bespoke database table to model every drip state.
  • No manual glue to correlate events and timers.

The entire drip lives as code, with durable sleeps in between.


Putting It Together: A Multi-Step, Multi-Day Workflow

Let’s wire a more “real” example: a post-purchase workflow for an e‑commerce app.

Flow:

  1. When an order is placed, create internal records.
  2. Call a fulfillment provider.
  3. Wait 48 hours (or until shipment event—omitted here for simplicity).
  4. Send a post-purchase NPS survey.
  5. If the user doesn’t respond in 5 days, send a reminder.
export const postPurchaseFlow = inngest.createFunction(
  { id: "post-purchase-flow" },
  { event: "shop/order.placed" },
  async ({ event, step }) => {
    const order = event.data;

    // Step 1: Persist order internally
    const dbOrder = await step.run("persist-order", async () => {
      return db.order.upsert({
        where: { externalId: order.id },
        update: {},
        create: {
          externalId: order.id,
          userId: order.userId,
          total: order.total,
        },
      });
    });

    // Step 2: Send to fulfillment provider
    const fulfillment = await step.run("call-fulfillment-provider", async () => {
      const res = await fetch("https://fulfill.example.com/orders", {
        method: "POST",
        body: JSON.stringify({
          orderId: dbOrder.id,
          items: order.items,
        }),
      });

      if (!res.ok) {
        throw new Error(`Fulfillment failed: ${res.status}`);
      }

      return res.json();
    });

    // Step 3: Wait 48h before NPS
    await step.sleep("wait-for-nps", "48h");

    // Step 4: Send NPS survey
    await step.run("send-nps-email", async () => {
      await sendEmail({
        to: order.customerEmail,
        subject: "How was your experience?",
        body: "We’d love your feedback on your recent order.",
      });
    });

    // Step 5: Wait 5 days, then send reminder
    await step.sleep("wait-for-nps-reminder", "120h");

    await step.run("send-nps-reminder", async () => {
      await sendEmail({
        to: order.customerEmail,
        subject: "Quick reminder: Tell us how we did",
        body: "It’ll only take 30 seconds.",
      });
    });

    return { orderId: dbOrder.id, fulfillment };
  }
);

Benefits from Inngest’s primitives:

  • If the fulfillment provider is flaky, only the fulfillment step retries.
  • If the NPS email template breaks, you see that step failing explicitly.
  • You can replay the workflow for a specific order from Traces without triggering the earlier steps again (thanks to checkpointing).

Local Development: Run and Test Multi-Step Workflows

You can develop and test all of this locally with Inngest’s dev server:

npx --ignore-scripts=false inngest-cli dev

Then:

  • Fire test events (e.g., shop/order.placed) to your dev server.
  • Watch runs in the local Traces UI.
  • Inspect each step’s inputs/outputs, sleep durations, and retries.

As a former backend engineer who used to wire workers + SQS + Redis + cron just to get similar behavior, this is where the value really shows up—no more rebuilding the “queue stack” to test multi-step flows.


Observability: Traces, Steps, and Replay

Every step.run() and step.sleep() becomes a first-class node in Inngest Traces:

  • You see step-level timelines (when it started, how long it took, when it retried).
  • Each step captures structured logs and inputs/outputs.
  • From the UI you can query, cancel, or replay runs—no custom admin tooling.

Replay + checkpointing are especially important for longer multi-step workflows:

  • If a bug in the “send NPS reminder” step is fixed, you can replay affected runs.
  • Earlier steps stay skipped because they have already succeeded.
  • This keeps multi-day workflows resilient even when your code changes.

Common Pitfalls and How to Avoid Them

1. Doing too much work outside of Steps

If you put significant logic outside of step.run() (e.g., at the top of the handler), that work:

  • Won’t be retried automatically.
  • Won’t show up as its own step in Traces.
  • Might get executed more than once on replay.

Fix: Wrap any meaningful business logic or side effects in step.run() so it’s durable and observable.

2. Treating step.sleep() like a normal in-process sleep

step.sleep() doesn’t block a thread or keep a lambda warm—it marks a durable pause. That means:

  • You can use long durations safely (hours, days, weeks).
  • You shouldn’t rely on in-memory state after a sleep; rely on the event payload, step results, or your DB instead.

3. Ignoring idempotency for external side effects

Even with checkpoints, you still want step bodies to be safe on re-execution:

  • Use unique identifiers when talking to external APIs.
  • Check existing state before creating new resources.

This makes retries and replays predictable.


When to Add Flow Control on Top of Multi-Step Workflows

Once your multi-step workflows are in production and multi-tenant (e.g., many customers hitting the same flow), you often need to prevent noisy neighbors from overwhelming:

  • A shared external API (e.g., rate-limited CRM).
  • Your own database.
  • A downstream provider like an email or payment service.

Inngest’s Flow Control lets you add:

  • Concurrency keys (per-tenant limits).
  • Throttling and rate limits.
  • Batching and prioritization.

All without rewriting the workflow logic itself. You keep your step.run() / step.sleep() steps as-is, and express flow control as configuration.


Summary: A Simple Pattern for Durable Multi-Step Workflows

To implement a multi-step workflow in Inngest using step.run() and step.sleep():

  1. Create an Inngest function with inngest.createFunction(...) triggered by an event, API call, or schedule.
  2. Break your logic into named step.run() blocks that map to real business actions or failure domains (DB writes, external APIs, emails).
  3. Use step.sleep() to express delays and long waits directly in code instead of cron or ad-hoc schedulers.
  4. Rely on checkpointing and retries to avoid bespoke retry logic and idempotency frameworks—keep steps small and idempotent.
  5. Use Traces and Replay to inspect, debug, and recover runs without building internal tooling.

If you’re ready to turn a multi-step flow into something durable and observable—without rebuilding queues, workers, and schedulers—Inngest’s step.run() and step.sleep() give you the core primitives you need.

Get Started

How do I implement a multi-step workflow in Inngest using step.run and step.sleep? | Durable Workflow Orchestration | Codeables | Codeables