Steps: Breaking Work into Durable Units
In Article 4 you wrote your first Inngest function with two step.run() calls and watched it execute in the Dev Server. It worked — but you might still have lingering questions.
Why do I have to wrap things in step.run()? What actually happens if I don't? How do steps share data with each other? Can steps run in parallel? What rules do I have to follow to keep things correct?
Those are exactly the right questions. This article answers all of them.
By the end you'll understand not just how to use steps, but why they work the way they do — which makes debugging ten times easier when something unexpected happens.
Quick Reference
What a step is: A named, checkpointed unit of work inside an Inngest function. Its result is saved after success. On retry, completed steps are skipped and their saved results are replayed.
The golden rule: Any code with a side effect (database write, API call, email send) belongs inside a step.run(). Code outside steps re-runs on every function execution.
Parallel steps: Don't await individual steps — collect them as promises, then await Promise.all([...]).
Data passing: The return value of step.run() is the saved result. Store it in a variable and use it in subsequent steps.
Step limits: Max 1,000 steps per function. Max 4 MB return value per step. Max 32 MB total state per run.
What You Need to Know First
Required reading (in order):
- Event-Driven Architecture: Why Your App Needs It
- Events, Queues, and Workers: The Building Blocks
- Inngest: What It Is and How It Fits In
- Your First Inngest Function
You should be comfortable with:
- Writing async/await TypeScript
- Basic Inngest setup: client, serve endpoint,
inngest.createFunction() - The concept of memoization (saving a result so you don't recompute it)
What We'll Cover in This Article
By the end of this guide, you'll understand:
- What happens inside a function when steps are (and aren't) present
- The re-execution model — why your handler runs multiple times per function run
- The golden rule: what belongs inside a step vs. outside
- How to pass data between sequential steps
- How to run steps in parallel with
Promise.all() - Step IDs — naming rules, stability requirements, and loop counters
- The step reference table — every
step.*method and what it does - Common step mistakes and how to recognise them
What We'll Explain Along the Way
- Memoization (in the Inngest context)
- Determinism (why your code outside steps must be predictable)
- The
optimizeParallelismoption (for functions with many parallel steps)
Part 1: The Re-Execution Model (The Foundation of Everything)
Before we talk about steps, we need to lock in your understanding of the most important — and most surprising — thing about Inngest functions.
Your function handler runs once per step, not once per function call.
Let's make this viscerally real. Say you have a function with three steps:
export const myFunction = inngest.createFunction(
{ id: "my-function" },
{ event: "demo/run" },
async ({ event, step }) => {
console.log("Handler started");
const result1 = await step.run("step-one", async () => {
console.log(" → Running step one");
return { value: "A" };
});
const result2 = await step.run("step-two", async () => {
console.log(" → Running step two");
return { value: "B" };
});
const result3 = await step.run("step-three", async () => {
console.log(" → Running step three");
return { value: "C" };
});
return { result1, result2, result3 };
},
);
You might expect the console output to be:
Handler started
→ Running step one
→ Running step two
→ Running step three
But here's what actually happens:
Execution #1:
Handler started
→ Running step one ← step one executes, result saved
Execution #2:
Handler started ← handler starts again from the top
[step-one skipped] ← saved result replayed, code NOT run
→ Running step two ← step two executes, result saved
Execution #3:
Handler started ← handler starts again from the top
[step-one skipped] ← saved result replayed
[step-two skipped] ← saved result replayed
→ Running step three ← step three executes, result saved
Function complete.
"Handler started" prints three times — once per execution. "→ Running step one" prints once — only during the execution where it runs for the first time.
This is the re-execution model. As Inngest's execution documentation describes: "The function is re-executed, this time with the event payload data and the state of the previous execution in JSON. The next step is discovered. The previous result is found in the state of previous executions... The step's code is not executed, instead the SDK injects the result."
Why this design?
It makes functions work on serverless platforms. Each execution is a short, independent HTTP call to your /api/inngest endpoint. There's no long-running process keeping state in memory. Each call is stateless — the state travels with the request, carried by Inngest.
The tradeoff is this re-execution behaviour. Once you've internalised it, everything about step design falls into place naturally.
Part 2: The Golden Rule — What Belongs Inside a Step
Here is the most important rule in this entire article:
Any code that has a side effect, calls an external service, or produces a non-deterministic result must live inside
step.run().
Code outside a step runs on every execution. That means it runs 3 times in our example above, once before each step executes. If that code sends an email, charges a card, writes to a database, or generates a random number — it will do so repeatedly and unpredictably.
Let's look at dangerous patterns:
❌ Dangerous: Side effects outside a step
async ({ event, step }) => {
// ❌ This runs on EVERY execution (3 times for a 3-step function)
await db.logs.insert({
message: "Function started",
userId: event.data.userId,
});
const profile = await step.run("create-profile", async () => {
return await db.profiles.create(event.data);
});
// ❌ This also runs on every execution
await analytics.track("profile-created", { userId: event.data.userId });
await step.run("send-email", async () => {
await emailService.sendWelcome(event.data.email);
});
};
The db.logs.insert and analytics.track calls happen on every re-execution — 2 times in this 2-step function. You'd end up with duplicate log entries and duplicate analytics events.
✅ Safe: Side effects inside steps
async ({ event, step }) => {
// ✅ Runs exactly once — checkpointed
await step.run("log-start", async () => {
await db.logs.insert({
message: "Function started",
userId: event.data.userId,
});
});
const profile = await step.run("create-profile", async () => {
return await db.profiles.create(event.data);
});
// ✅ Runs exactly once — checkpointed
await step.run("track-analytics", async () => {
await analytics.track("profile-created", { userId: event.data.userId });
});
await step.run("send-email", async () => {
await emailService.sendWelcome(event.data.email);
});
};
What's safe outside a step?
Not everything needs a step. Code outside steps is fine when it's:
- Pure computation — transforming data that was returned by a previous step
- Conditional logic —
if/switchstatements based on step results or event data - Synchronous, in-memory operations — string manipulation, array filtering, object construction
async ({ event, step }) => {
const rawData = await step.run("fetch-data", async () => {
return await api.getData(event.data.id);
});
// ✅ Safe outside a step — pure transformation of step result
const processedItems = rawData.items.filter((item) => item.active);
const summary = processedItems.map((item) => item.name).join(", ");
// ✅ Safe outside a step — conditional logic
if (processedItems.length === 0) {
// Early return — no more steps to run
return { status: "no-items", summary };
}
await step.run("save-results", async () => {
await db.results.insert({ items: processedItems, summary });
});
return { status: "complete", count: processedItems.length, summary };
};
The single side effect rule
Inngest's multi-step guide makes a subtle but critical point: each step should have a single side effect.
Consider this problematic step:
// ❌ Two side effects in one step — dangerous if the second one fails
await step.run("create-alert", async () => {
const alertId = await createAlert(); // Side effect #1
await sendAlertLinkToSlack(alertId); // Side effect #2 — uses result of #1
});
If createAlert() succeeds but sendAlertLinkToSlack() fails, the step retries. On retry, createAlert() runs again — creating a duplicate alert. Then sendAlertLinkToSlack() runs with the new alert ID.
Split them:
// ✅ Each step has one side effect — safe to retry independently
const alertId = await step.run("create-alert", async () => {
return await createAlert();
});
await step.run("send-slack-notification", async () => {
await sendAlertLinkToSlack(alertId);
});
Now if the Slack notification fails, only that step retries. createAlert() is never called again.
Part 3: Passing Data Between Steps
Steps are sequential by nature — each one builds on the last. The way you share data between them is simple: the return value of step.run() is the saved result, available to everything that follows.
export const processOrder = inngest.createFunction(
{ id: "process-order" },
{ event: "order/placed" },
async ({ event, step }) => {
// Step 1 returns a charge result
const charge = await step.run("charge-customer", async () => {
return await paymentService.charge({
customerId: event.data.customerId,
amountCents: event.data.amountCents,
});
// Returns: { chargeId: "ch_xyz", status: "succeeded" }
});
// Step 2 uses charge.chargeId from step 1
const fulfillment = await step.run("create-fulfillment", async () => {
return await warehouseService.createPickOrder({
orderId: event.data.orderId,
chargeId: charge.chargeId, // ← data from step 1
items: event.data.items,
});
// Returns: { fulfillmentId: "ful_abc", estimatedShipDate: "2025-04-10" }
});
// Step 3 uses data from both step 1 and step 2
await step.run("send-confirmation-email", async () => {
await emailService.sendOrderConfirmation({
to: event.data.customerEmail,
orderId: event.data.orderId,
chargeId: charge.chargeId, // ← from step 1
fulfillmentId: fulfillment.fulfillmentId, // ← from step 2
estimatedShipDate: fulfillment.estimatedShipDate, // ← from step 2
});
});
return {
orderId: event.data.orderId,
chargeId: charge.chargeId,
fulfillmentId: fulfillment.fulfillmentId,
};
},
);
This works because when the handler re-executes for step 2, Inngest sends the saved result of step 1 in the request. The SDK injects it as the return value of await step.run("charge-customer", ...) — the callback doesn't run, but the saved result is returned as if it had. So charge.chargeId is available even though the charge step didn't actually execute this time.
Important: Step results must be JSON-serialisable
Because step results are saved externally as JSON, everything you return from a step must be serialisable. This means:
// ✅ Fine — plain objects, strings, numbers, arrays, booleans, null
return { chargeId: "ch_xyz", amount: 5000, status: "succeeded" };
// ✅ Fine — Date as ISO string (not as a Date object)
return { createdAt: new Date().toISOString() };
// ❌ Not fine — class instances lose their methods
return new Payment({ chargeId: "ch_xyz" }); // Methods stripped, becomes plain object
// ❌ Not fine — functions can't be serialised
return { transform: (x: number) => x * 2 };
// ❌ Not fine — undefined values in objects
return { chargeId: "ch_xyz", metadata: undefined }; // undefined is stripped
As a rule: if JSON.stringify() gives you exactly what you put in, you're fine. If not, restructure the return value.
Part 4: Parallel Steps
Sequential steps are powerful, but sometimes you want to do multiple things at once. Why wait 800ms for an email to send and then 500ms for a CRM update when you could do both simultaneously in 800ms total?
Inngest supports parallel steps through standard JavaScript promises. The pattern is:
- Call
step.run()withoutawaitto create unresolved promises - Collect them
await Promise.all([...])to run all of them simultaneously
export const postPaymentFlow = inngest.createFunction(
{ id: "post-payment-flow" },
{ event: "stripe/charge.created" },
async ({ event, step }) => {
// ── Sequential step first ─────────────────────────────────────────
const user = await step.run("fetch-user", async () => {
return await db.users.findById(event.data.customerId);
});
// ── Parallel steps ────────────────────────────────────────────────
// Don't await these individually — collect them as promises
const sendEmail = step.run("send-confirmation-email", async () => {
return await emailService.sendPaymentConfirmation({
to: user.email,
amount: event.data.amount,
});
});
const updateCRM = step.run("update-crm-record", async () => {
return await crmService.recordPayment({
userId: user.id,
amount: event.data.amount,
chargeId: event.data.id,
});
});
const grantAccess = step.run("grant-premium-access", async () => {
return await accessService.upgrade(user.id, "premium");
});
// Now await all three in parallel
// Promise.all resolves when ALL steps complete
const [emailResult, crmResult, accessResult] = await Promise.all([
sendEmail,
updateCRM,
grantAccess,
]);
// ── Sequential step after the parallel group ──────────────────────
await step.run("log-completion", async () => {
await db.events.insert({
type: "payment-processed",
userId: user.id,
emailId: emailResult.messageId,
crmId: crmResult.recordId,
accessLevel: accessResult.level,
});
});
return { status: "complete", userId: user.id };
},
);
What happens under the hood with parallel steps?
When Inngest encounters Promise.all([sendEmail, updateCRM, grantAccess]), it recognises that multiple steps are ready to run simultaneously. It dispatches them as separate HTTP calls to your endpoint — all three execute in parallel on Inngest's side, even if your function handler is processing them one at a time in terms of code execution.
As Inngest's step parallelism docs note: "When each step is finished, Inngest will aggregate each step's state and re-invoke the function with all state available."
The optimizeParallelism option
If your function fans out into many parallel steps — say, processing each item in an array — the default parallel execution can generate a lot of HTTP round-trips to your endpoint. Each step completion triggers a new execution to discover the next step.
For functions with many parallel steps (more than ~10), enable optimizeParallelism:
export const processBatch = inngest.createFunction(
{
id: "process-batch",
optimizeParallelism: true, // Reduces HTTP round-trips significantly
},
{ event: "data/batch.ready" },
async ({ event, step }) => {
const chunks = chunkArray(event.data.items, 10);
// With optimizeParallelism: true, these don't each trigger
// a separate HTTP call — Inngest batches the state updates
const results = await Promise.all(
chunks.map((chunk, index) =>
step.run(`process-chunk-${index}`, () => processChunk(chunk)),
),
);
await step.run("aggregate-results", () => {
return aggregateResults(results);
});
},
);
Part 5: Step IDs — The Rules That Matter
Step IDs seem trivial until they cause a subtle bug. Here's everything you need to know.
IDs must be unique within a function
Each step in a function needs a unique ID. Duplicate IDs within the same function cause undefined behaviour — Inngest uses the ID to look up saved state, so two steps with the same ID would collide.
// ❌ Duplicate IDs — the second step will return the first step's result
await step.run("process-data", async () => processUserData(event.data));
await step.run("process-data", async () => processOrderData(event.data)); // ← same ID!
// ✅ Unique IDs
await step.run("process-user-data", async () => processUserData(event.data));
await step.run("process-order-data", async () => processOrderData(event.data));
IDs must be stable across deployments
This is the rule that surprises most developers. If you rename a step ID after a function has started running in production, any in-progress runs that completed that step will not find it in their saved state — they'll execute it again.
// ─── v1 of your function ───────────────────────────────────────
await step.run("create-profile", async () => {
return await db.profiles.create(event.data);
});
// ─── v2: you renamed the step — DANGER ─────────────────────────
// Any run that was partway through v1 and had "create-profile" saved
// will not find "setup-user-profile" in its state.
// The step runs again — potentially creating a duplicate profile.
await step.run("setup-user-profile", async () => {
// ← renamed!
return await db.profiles.create(event.data);
});
As Inngest's versioning docs explain: changing a step ID forces re-execution. If you change logic inside a step but keep the same ID, in-progress runs use the memoised (old) result. New runs use the new logic. This is usually what you want.
Naming convention: Use lowercase kebab-case with a verb describing what the step does: send-welcome-email, create-billing-trial, notify-slack. Avoid names that reference implementation details you might refactor (call-mailgun-api, insert-postgres-row) — name what the step does, not how.
Step IDs in loops
What if you're running the same step inside a loop — say, processing each item in an array? You can't use the same ID for each iteration. The solution is to include the loop index or a unique identifier in the step ID:
export const processImages = inngest.createFunction(
{ id: "process-images" },
{ event: "images/batch.uploaded" },
async ({ event, step }) => {
const results = [];
for (const image of event.data.images) {
// Include image.id in the step ID to make each iteration unique
const result = await step.run(`resize-image-${image.id}`, async () => {
return await imageService.resize({
url: image.url,
width: 800,
height: 600,
});
});
results.push(result);
}
return { processed: results.length };
},
);
Inngest also tracks a counter for each step ID automatically, so if you truly need the same ID in a loop (e.g. you don't have a unique identifier), the counter differentiates them. But using a meaningful identifier in the ID is clearer and easier to debug.
Part 6: The Full Step API Reference
step.run() is the most common step method, but it's not the only one. Here's the complete reference. We'll cover each in depth in later articles — this is your map.
step.run(id, fn) — run durable work
The core step. Runs a function, saves its result, retries on failure.
const result = await step.run("fetch-user", async () => {
return await db.users.findById(event.data.userId);
});
// result is the saved return value — { id, email, name, ... }
- Retries: Uses the function's
retriesconfig. Default is 3 attempts (4 total, including the first). - Return value: Must be JSON-serialisable.
- Error handling: Throwing an error marks the step as failed and triggers retry.
step.sleep(id, duration) — pause execution
Pauses the function for a fixed duration. The function is not running during the sleep — Inngest schedules it to resume after the duration. This means a step.sleep("wait", "24h") doesn't consume a serverless function slot for 24 hours; it just waits on Inngest's side.
await step.run("send-welcome-email", async () => {
await emailService.sendWelcome(event.data.email);
});
// Pause for 24 hours — completely free, no compute consumed
await step.sleep("wait-24-hours", "24h");
// Only runs if the user hasn't logged in yet
await step.run("send-activation-reminder", async () => {
const user = await db.users.findById(event.data.userId);
if (!user.lastLoginAt) {
await emailService.sendActivationReminder(user.email);
}
});
Duration formats: "30s", "10m", "2h", "7d". Up to 1 year.
step.sleepUntil(id, datetime) — pause until a time
Like step.sleep(), but pauses until a specific point in time rather than a duration.
const subscription = await step.run("fetch-subscription", async () => {
return await db.subscriptions.findById(event.data.subscriptionId);
});
// Sleep until 3 days before the subscription renews
const reminderDate = new Date(subscription.renewsAt);
reminderDate.setDate(reminderDate.getDate() - 3);
await step.sleepUntil("wait-until-reminder-date", reminderDate);
await step.run("send-renewal-reminder", async () => {
await emailService.sendRenewalReminder(event.data.email);
});
step.waitForEvent(id, options) — pause until an event arrives
Pauses the function and waits for a specific event to arrive. If the event arrives, its payload is returned. If the timeout expires first, null is returned.
This is one of Inngest's most powerful primitives — we cover it in full in Article 8: Step Coordination — Waiting for External Events (coming soon).
await step.run("send-welcome-email", async () => {
await emailService.sendWelcome(event.data.email);
});
// Wait up to 24h for the user to create their first post
const firstPost = await step.waitForEvent("wait-for-first-post", {
event: "post/created",
match: "data.userId", // event.data.userId must match our event.data.userId
timeout: "24h",
});
if (firstPost === null) {
// User hasn't posted yet after 24 hours
await step.run("send-activation-nudge", async () => {
await emailService.sendActivationNudge(event.data.email);
});
}
step.sendEvent(id, event) — send an event from within a function
Sends an event from inside a running function. This is how you chain workflows together — one function's output triggers another function.
const processedData = await step.run("process-data", async () => {
return await processor.run(event.data.fileId);
});
// Trigger a separate function to handle the next phase
await step.sendEvent("trigger-notification-flow", {
name: "data/processing.complete",
data: {
fileId: event.data.fileId,
resultId: processedData.resultId,
userId: event.data.userId,
},
});
step.invoke(id, options) — call another Inngest function
Invokes another Inngest function as a step and waits for its result. Unlike step.sendEvent(), which fires and forgets, step.invoke() blocks until the invoked function completes and returns its result.
const summary = await step.invoke("generate-report-summary", {
function: generateSummaryFn, // a reference to another createFunction()
data: {
reportId: event.data.reportId,
},
});
// summary is the return value of generateSummaryFn
await step.run("send-report-email", async () => {
await emailService.sendReport({
to: event.data.email,
summary: summary.text,
reportId: event.data.reportId,
});
});
Part 7: Common Mistakes (And How to Spot Them)
Mistake 1: Forgetting await on a step
// ❌ Missing await — the step is created but not awaited
// result is a Promise, not the step's return value
const result = step.run("fetch-data", async () => {
return await api.getData();
});
// Using result here will give you a Promise object, not the data
console.log(result.items); // undefined — result is a Promise
// ✅ Always await step.run()
const result = await step.run("fetch-data", async () => {
return await api.getData();
});
console.log(result.items); // the actual data
Exception: Don't await when setting up parallel steps. Collect them unawaited, then await Promise.all([...]).
Mistake 2: Non-deterministic code outside a step
// ❌ Math.random() runs on every execution — different value each time
// Any code that depends on this will behave inconsistently
const userId = Math.random().toString(36).slice(2);
await step.run("create-user", async () => {
return await db.users.create({ id: userId, ...event.data });
});
// ✅ Generate the random value inside the step
const user = await step.run("create-user", async () => {
const userId = Math.random().toString(36).slice(2);
return await db.users.create({ id: userId, ...event.data });
});
The same applies to Date.now(), new Date(), or any other value that changes between executions. If you need a timestamp that's consistent across retries, generate it in a step and use the saved value.
Mistake 3: Multiple side effects in one step
We covered this in Part 2, but it bears repeating with a concrete example:
// ❌ Two writes in one step — if the second fails, the first re-runs on retry
await step.run("setup-account", async () => {
const account = await db.accounts.create(event.data); // side effect 1
await billingService.createTrial(account.id); // side effect 2
});
// ✅ Separate steps — each retries independently
const account = await step.run("create-account-record", async () => {
return await db.accounts.create(event.data);
});
await step.run("create-billing-trial", async () => {
await billingService.createTrial(account.id);
});
Mistake 4: Returning a non-serialisable value
// ❌ Returning a class instance — methods are stripped by JSON serialisation
const user = await step.run("fetch-user", async () => {
return await UserModel.findById(event.data.userId);
// UserModel instance has methods like .save(), .delete() — these are LOST
});
user.save(); // ❌ TypeError: user.save is not a function
// ✅ Return a plain object
const user = await step.run("fetch-user", async () => {
const model = await UserModel.findById(event.data.userId);
return { id: model.id, email: model.email, name: model.name }; // plain object
});
Complete Example: A Real-World Multi-Step Function
Let's put everything together with a realistic example — a video upload processing pipeline that uses sequential steps, parallel steps, and data passing.
export const processVideoUpload = inngest.createFunction(
{
id: "process-video-upload",
retries: 3,
},
{ event: "video/uploaded" },
async ({ event, step }) => {
// ── Phase 1: Validate the upload ───────────────────────────────────
const videoMeta = await step.run(
"validate-and-extract-metadata",
async () => {
return await videoService.extractMetadata(event.data.storageUrl);
// Returns: { duration, resolution, codec, fileSize }
},
);
// ── Phase 2: Parallel transcoding ──────────────────────────────────
// All three resolution steps start simultaneously
const transcode720p = step.run("transcode-720p", async () => {
return await transcoder.convert({
inputUrl: event.data.storageUrl,
resolution: "720p",
outputKey: `${event.data.videoId}/720p.mp4`,
});
});
const transcode1080p = step.run("transcode-1080p", async () => {
return await transcoder.convert({
inputUrl: event.data.storageUrl,
resolution: "1080p",
outputKey: `${event.data.videoId}/1080p.mp4`,
});
});
const generateThumbnail = step.run("generate-thumbnail", async () => {
return await thumbnailService.generate({
inputUrl: event.data.storageUrl,
outputKey: `${event.data.videoId}/thumbnail.jpg`,
timestampSeconds: 5,
});
});
// Wait for all three to complete in parallel
const [p720, p1080, thumbnail] = await Promise.all([
transcode720p,
transcode1080p,
generateThumbnail,
]);
// ── Phase 3: Update the database record ────────────────────────────
const updatedVideo = await step.run("update-video-record", async () => {
return await db.videos.update(event.data.videoId, {
status: "ready",
duration: videoMeta.duration,
thumbnailUrl: thumbnail.url,
variants: {
"720p": p720.url,
"1080p": p1080.url,
},
});
});
// ── Phase 4: Notify the user ───────────────────────────────────────
await step.run("send-ready-notification", async () => {
await emailService.sendVideoReady({
to: event.data.uploaderEmail,
videoTitle: updatedVideo.title,
videoUrl: `https://myapp.com/videos/${event.data.videoId}`,
thumbnailUrl: thumbnail.url,
});
});
return {
videoId: event.data.videoId,
status: "ready",
duration: videoMeta.duration,
};
},
);
This function has five distinct phases. The transcoding and thumbnail generation all happen in parallel — if the video is 100MB, the total time is roughly max(720p time, 1080p time, thumbnail time) rather than their sum. The database update and notification wait for all of them to complete, using the results from each.
If the 1080p transcoding fails on first attempt, only that step retries. The 720p and thumbnail steps don't re-run. The function resumes from exactly where it broke.
Common Misconceptions
❌ Misconception: Steps run in separate processes or containers
Reality: Steps run inside your own application. The code executes in your Node.js process, with access to all your existing imports, environment variables, and database connections. What Inngest manages is when the steps run, how they're retried, and where their results are stored — not where your code physically executes.
❌ Misconception: You need a step for every line of code
Reality: Steps are for units of work with side effects, not for every operation. A function that fetches a user, transforms 50 array items, and inserts the result might only need two steps: one for the fetch and one for the insert. The array transformation happens outside any step — pure computation, runs on every execution, that's fine.
❌ Misconception: Parallel steps are faster for CPU-heavy work
Reality: Parallel steps shine for I/O-bound work (network calls, database writes, file operations) where each step spends most of its time waiting. For CPU-intensive computation (image processing, heavy calculations), parallelism is limited by your available CPU cores. Spreading CPU work across many parallel steps may not speed things up if they all end up on the same physical machine.
❌ Misconception: Changing step logic with the same ID re-executes for in-progress runs
Reality: In-progress runs that have already completed a step use the memoised result — even if you changed the code inside the step. The saved result doesn't update. Only new runs pick up the new logic. If you need in-progress runs to re-execute a step with new logic, change the step's ID.
Check Your Understanding
Quick Quiz
1. A function has steps A, B, and C running sequentially. Step B fails. After you fix the bug and the function retries — which steps actually execute their callback code?
Show Answer
Only step B (on retry) and then step C (for the first time, after B succeeds). Step A is skipped — its saved result is replayed from Inngest's state store. The whole point of steps is that each one retries independently without repeating work that already succeeded.
2. What's wrong with this code?
async ({ event, step }) => {
const timestamp = new Date().toISOString(); // generated outside a step
await step.run("create-record", async () => {
await db.records.insert({ id: event.data.id, createdAt: timestamp });
});
await step.run("send-email", async () => {
await emailService.send({ to: event.data.email, timestamp });
});
};
Show Answer
new Date().toISOString() runs outside a step, so it re-executes with every function execution. In a 2-step function, the handler runs twice. timestamp will be a different value in each execution. This means:
- The DB record is created with timestamp value from execution #1
- The email is sent with timestamp value from execution #2
They won't match. Fix: generate the timestamp inside a step, save it, and use the saved value for both subsequent steps.
3. You want to run 5 steps in parallel. How do you set that up?
Show Answer
Call step.run() without await for each step to get unresolved promises, then pass them all to Promise.all():
const step1 = step.run("step-one", () => doWork1());
const step2 = step.run("step-two", () => doWork2());
const step3 = step.run("step-three", () => doWork3());
const step4 = step.run("step-four", () => doWork4());
const step5 = step.run("step-five", () => doWork5());
const [r1, r2, r3, r4, r5] = await Promise.all([
step1,
step2,
step3,
step4,
step5,
]);
All five execute in parallel. Promise.all resolves when the slowest one finishes.
Hands-On Challenge
Take the handleUserSignup function you built in Article 4 and refactor it to run the email send and billing setup in parallel (they don't depend on each other) while still keeping the profile creation as the first sequential step.
Current structure:
- Create profile (sequential)
- Send welcome email (sequential — waits for step 1)
- Create billing trial (sequential — waits for step 2)
Target structure:
- Create profile (sequential)
- Send welcome email + Create billing trial (parallel — both start after step 1)
- (function complete)
See the Solution
export const handleUserSignup = inngest.createFunction(
{ id: "handle-user-signup", retries: 3 },
{ event: "user/account.created" },
async ({ event, step }) => {
// Step 1: Must happen first — email + billing both need the profile
const profile = await step.run("create-user-profile", async () => {
return await db.profiles.create({
userId: event.data.userId,
email: event.data.email,
name: event.data.name,
});
});
// Steps 2 + 3: These are independent of each other — run in parallel
const sendEmail = step.run("send-welcome-email", async () => {
return await emailService.sendWelcome({
to: event.data.email,
name: event.data.name,
profileId: profile.profileId,
});
});
const createTrial = step.run("create-billing-trial", async () => {
return await billingService.createTrial(event.data.userId);
});
const [emailResult, trialResult] = await Promise.all([
sendEmail,
createTrial,
]);
return {
profileId: profile.profileId,
emailMessageId: emailResult.messageId,
trialId: trialResult.trialId,
status: "signup complete",
};
},
);
The total signup time is now createProfile + max(sendEmail, createBillingTrial) — not createProfile + sendEmail + createBillingTrial.
Summary: Key Takeaways
- The re-execution model: Your function handler runs once per step, not once per function call. Code outside steps runs on every execution. This is by design — it's what makes serverless and durability compatible.
- The golden rule: Side effects, API calls, and non-deterministic code belong inside
step.run(). Pure computation and conditional logic can safely live outside. - Single side effect per step: Each
step.run()should do exactly one thing. Two operations in one step means both repeat if the second one fails. - Data passing: Return values from
step.run()are the saved results, available to all subsequent steps. - Parallel steps: Don't await individually. Collect as promises, then
await Promise.all([...]). - Step IDs must be unique, stable, and descriptive. Use
kebab-caseverbs. Never rename a step ID while runs are in flight. - Step results must be JSON-serialisable. Plain objects, strings, numbers, arrays — yes. Class instances with methods, functions, undefined values — no.
- Step limits: 1,000 steps per function, 4 MB return value per step, 32 MB total state per run.
What's Next?
You now have a thorough understanding of steps — Inngest's foundational building block. You know the re-execution model, the golden rule, how to share data, how to run things in parallel, and the rules for step IDs.
In Article 6: Retries, Timeouts, and Error Handling (coming soon), we go deep on what happens when things go wrong. How does exponential backoff work? What's a NonRetriableError and when do you use it? How do you handle errors at the step level vs. the function level? How does step.sleep() interact with retry logic? All of that, next.
Version Information
Tested with:
inngest:^4.1.x- Node.js: v18.x, v20.x, v22.x
- TypeScript: 5.x
Limits (accurate as of Inngest SDK v4):
- Maximum steps per function: 1,000
- Maximum step output size: 4 MB
- Maximum total function run state: 32 MB
- Maximum sleep/waitForEvent duration: 1 year
Further reading:
- Steps & Workflows — official steps overview
- step.run() Reference — full API reference
- Multi-Step Functions Guide — patterns and examples from Inngest docs
- Step Parallelism Guide — parallel steps,
optimizeParallelism, andPromise.racebehaviour - How Inngest Functions Are Executed — the deep technical explanation of the re-execution model
- Versioning — what happens when you change step IDs mid-deployment