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 CodeablesHow do I implement a multi-step workflow in Inngest using step.run and step.sleep?
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 building 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-analyticsfails butcreate-db-recordsucceeds, 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:
- When an order is placed, create internal records.
- Call a fulfillment provider.
- Wait 48 hours (or until shipment event—omitted here for simplicity).
- Send a post-purchase NPS survey.
- 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():
- Create an Inngest function with
inngest.createFunction(...)triggered by an event, API call, or schedule. - Break your logic into named
step.run()blocks that map to real business actions or failure domains (DB writes, external APIs, emails). - Use
step.sleep()to express delays and long waits directly in code instead of cron or ad-hoc schedulers. - Rely on checkpointing and retries to avoid bespoke retry logic and idempotency frameworks—keep steps small and idempotent.
- 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.