inngest-steps

Use when implementing delays that must survive process restarts (e.g., 24-hour cart abandonment, scheduled follow-ups), waiting for human approval or external…

INSTALLATION
npx skills add https://github.com/inngest/inngest-skills --skill inngest-steps
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Inngest 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 null return (means timeout, event never arrived)
// 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!

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills β†’

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free Β· no credit card