Inngest-skills inngest-steps
Use Inngest step methods to build durable workflows. Covers step.run, step.sleep, step.waitForEvent, step.waitForSignal, step.sendEvent, step.invoke, step.ai, and patterns for loops and parallel execution.
git clone https://github.com/inngest/inngest-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/inngest/inngest-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/inngest-steps" ~/.claude/skills/inngest-inngest-skills-inngest-steps && rm -rf "$T"
skills/inngest-steps/SKILL.mdInngest Steps
Build robust, durable workflows with Inngest's step methods. Each step is a separate HTTP request that can be independently retried and monitored.
These skills are focused on TypeScript. For Python or Go, refer to the Inngest documentation for language-specific guidance. Core concepts apply across all languages.
Core Concept
🔄 Critical: Each step re-runs your function from the beginning. Put ALL non-deterministic code (API calls, DB queries, randomness) inside steps, never outside.
📊 Step Limits: Every function has a maximum of 1,000 steps and 4MB total step data.
// ❌ WRONG - will run 4 times export default inngest.createFunction( { id: "bad-example", triggers: [{ event: "test" }] }, async ({ step }) => { console.log("This logs 4 times!"); // Outside step = bad await step.run("a", () => console.log("a")); await step.run("b", () => console.log("b")); await step.run("c", () => console.log("c")); } ); // ✅ CORRECT - logs once each export default inngest.createFunction( { id: "good-example", triggers: [{ event: "test" }] }, async ({ step }) => { await step.run("log-hello", () => console.log("hello")); await step.run("a", () => console.log("a")); await step.run("b", () => console.log("b")); await step.run("c", () => console.log("c")); } );
step.run()
Execute retriable code as a step. Each step ID can be reused - Inngest automatically handles counters.
// Basic usage const result = await step.run("fetch-user", async () => { const user = await db.user.findById(userId); return user; // Always return useful data }); // Synchronous code works too const transformed = await step.run("transform-data", () => { return processData(result); }); // Side effects (no return needed) await step.run("send-notification", async () => { await sendEmail(user.email, "Welcome!"); });
✅ DO:
- Put ALL non-deterministic logic inside steps
- Return useful data for subsequent steps
- Reuse step IDs in loops (counters handled automatically)
❌ DON'T:
- Put deterministic logic in steps unnecessarily
- Forget that each step = separate HTTP request
step.sleep()
Pause execution without using compute time.
// Duration strings await step.sleep("wait-24h", "24h"); await step.sleep("short-delay", "30s"); await step.sleep("weekly-pause", "7d"); // Use in workflows await step.run("send-welcome", () => sendEmail(email)); await step.sleep("wait-for-engagement", "3d"); await step.run("send-followup", () => sendFollowupEmail(email));
step.sleepUntil()
Sleep until a specific datetime.
const reminderDate = new Date("2024-12-25T09:00:00Z"); await step.sleepUntil("wait-for-christmas", reminderDate); // From event data const scheduledTime = new Date(event.data.remind_at); await step.sleepUntil("wait-for-scheduled-time", scheduledTime);
step.waitForEvent()
🚨 CRITICAL: waitForEvent ONLY catches events sent AFTER this step executes.
- ❌ Event sent before waitForEvent runs → will NOT be caught
- ✅ Event sent after waitForEvent runs → will be caught
- Always check for
return (means timeout, event never arrived)null
// Basic event waiting with timeout const approval = await step.waitForEvent("wait-for-approval", { event: "app/invoice.approved", timeout: "7d", match: "data.invoiceId" // Simple matching }); // Expression-based matching (CEL syntax) const subscription = await step.waitForEvent("wait-for-subscription", { event: "app/subscription.created", timeout: "30d", if: "event.data.userId == async.data.userId && async.data.plan == 'pro'" }); // Handle timeout if (!approval) { await step.run("handle-timeout", () => { // Approval never came return notifyAccountingTeam(); }); }
✅ DO:
- Use unique IDs for matching (userId, sessionId, requestId)
- Always set reasonable timeouts
- Handle null return (timeout case)
- Use with Realtime for human-in-the-loop flows
❌ DON'T:
- Expect events sent before this step to be handled
- Use without timeouts in production
Expression Syntax
In expressions,
event = the original triggering event, async = the new event being matched. See Expression Syntax Reference for full syntax, operators, and patterns.
step.waitForSignal()
Wait for unique signals (not events). Better for 1:1 matching.
const taskId = "task-" + crypto.randomUUID(); const signal = await step.waitForSignal("wait-for-task-completion", { signal: taskId, timeout: "1h", onConflict: "replace" // Required: "replace" overwrites pending signal, "fail" throws an error }); // Send signal elsewhere via Inngest API or SDK // POST /v1/events with signal matching taskId
When to use:
- waitForEvent: Multiple functions might handle the same event
- waitForSignal: Exact 1:1 signal to specific function run
step.sendEvent()
Fan out to other functions without waiting for results.
// Trigger other functions await step.sendEvent("notify-systems", { name: "user/profile.updated", data: { userId: user.id, changes: profileChanges } }); // Multiple events at once await step.sendEvent("batch-notifications", [ { name: "billing/invoice.created", data: { invoiceId } }, { name: "email/invoice.send", data: { email: user.email, invoiceId } } ]);
Use when: You want to trigger other functions but don't need their results in the current function.
step.invoke()
Call other functions and handle their results. Perfect for composition.
const computeSquare = inngest.createFunction( { id: "compute-square", triggers: [{ event: "calculate/square" }] }, async ({ event }) => { return { result: event.data.number * event.data.number }; } ); // Invoke and use result const square = await step.invoke("get-square", { function: computeSquare, data: { number: 4 } }); console.log(square.result); // 16, fully typed! // For cross-app invocation (when you can't import the function directly): import { referenceFunction } from "inngest"; const externalFn = referenceFunction({ appId: "other-app", functionId: "other-fn" }); const result = await step.invoke("call-external", { function: externalFn, data: { key: "value" } });
Warning: v4 Breaking Change: String function IDs (e.g.,
function: "my-app-other-fn") are no longer supported in step.invoke(). Use an imported function reference or referenceFunction() for cross-app calls.
Great for:
- Breaking complex workflows into composable functions
- Reusing logic across multiple workflows
- Map-reduce patterns
Patterns
Loops with Steps
Reuse step IDs - Inngest handles counters automatically.
const allProducts = []; let cursor = null; let hasMore = true; while (hasMore) { // Same ID "fetch-page" reused - counters handled automatically const page = await step.run("fetch-page", async () => { return shopify.products.list({ cursor, limit: 50 }); }); allProducts.push(...page.products); if (page.products.length < 50) { hasMore = false; } else { cursor = page.products[49].id; } } await step.run("process-products", () => { return processAllProducts(allProducts); });
Parallel Execution
Use Promise.all for parallel steps. In v4, parallel step execution is optimized by default
// Create steps without awaiting const sendEmail = step.run("send-email", async () => { return await sendWelcomeEmail(user.email); }); const updateCRM = step.run("update-crm", async () => { return await crmService.addUser(user); }); const createSubscription = step.run("create-subscription", async () => { return await subscriptionService.create(user.id); }); // Run all in parallel const [emailId, crmRecord, subscription] = await Promise.all([ sendEmail, updateCRM, createSubscription ]); // Parallel steps are optimized by default in v4 export default inngest.createFunction( { id: "parallel-heavy-function", triggers: [{ event: "process/batch" }] }, async ({ event, step }) => { const results = await Promise.all( event.data.items.map((item, i) => step.run(`process-item-${i}`, () => processItem(item)) ) ); } ); // ⚠️ Promise.race() behavior with v4's optimized parallelism: // All promises settle before race resolves. Use group.parallel() for true race: const winner = await group.parallel(async () => { return Promise.race([ step.run("fast-service", () => callFastService()), step.run("slow-service", () => callSlowService()) ]); }); // To disable optimized parallelism if needed: // At the client level: new Inngest({ id: "app", optimizeParallelism: false }) // At the function level: { id: "fn", optimizeParallelism: false, triggers: [...] }
See inngest-flow-control for concurrency and throttling options.
Chunking Jobs
Perfect for batch processing with parallel steps.
export default inngest.createFunction( { id: "process-large-dataset", triggers: [{ event: "data/process.large" }] }, async ({ event, step }) => { const chunks = chunkArray(event.data.items, 10); // Process chunks in parallel const results = await Promise.all( chunks.map((chunk, index) => step.run(`process-chunk-${index}`, () => processChunk(chunk)) ) ); // Combine results await step.run("combine-results", () => { return aggregateResults(results); }); } );
Key Gotchas
🔄 Function Re-execution: Code outside steps runs on every step execution ⏰ Event Timing: waitForEvent only catches events sent AFTER the step runs 🔢 Step Limits: Max 1,000 steps per function, 4MB per step output, 32MB per function run in total 📨 HTTP Requests: Checkpointing is enabled by default in v4, reducing HTTP overhead. For serverless platforms, configure
maxRuntime on the client
🔁 Step IDs: Can be reused in loops - Inngest handles counters
⚡ Parallelism: Use Promise.all for parallel steps (optimized by default in v4). Note that Promise.race() waits for all promises to settle — use group.parallel() for true race semantics
Common Use Cases
- Human-in-the-loop: waitForEvent + Realtime UI
- Multi-step onboarding: sleep between steps, waitForEvent for user actions
- Data processing: Parallel steps for chunked work
- External integrations: step.run for reliable API calls
- AI workflows: step.ai for durable LLM orchestration
- Function composition: step.invoke to build complex workflows
Remember: Steps make your functions durable, observable, and debuggable. Embrace them!