Skip to main content
Technology & EngineeringBackground Jobs Services240 lines

Inngest

"Inngest: event-driven functions, durable workflows, step functions, retries, cron, fan-out, sleep, Next.js integration"

Quick Summary27 lines
Inngest provides durable, event-driven functions that run reliably without infrastructure management. Functions are triggered by events, executed as step functions with automatic retries, and orchestrated through a declarative SDK. The core idea is that background work should be as simple as writing a function — Inngest handles queuing, retries, concurrency, observability, and state management. You ship functions, not infrastructure.

## Key Points

- **Keep steps small and idempotent.** Each step can retry independently; side effects should be safe to repeat.
- **Use step.sleep for delays** instead of setTimeout. Durable sleep costs nothing and survives restarts.
- **Type your events** with EventSchemas to get compile-time safety across your entire event-driven system.
- **Use waitForEvent** for human-in-the-loop or external system coordination instead of polling.
- **Set concurrency limits** to protect downstream services from being overwhelmed.
- **Return serializable data from steps.** Step outputs are persisted as JSON for checkpointing.
- **Use the Inngest dev server** during development for local testing with full visibility into function execution.
- **Register all functions** in a single serve() endpoint so Inngest can discover them via a single sync.
- **Giant monolithic steps.** A single step with 10 API calls means all 10 retry together on failure. Split into individual steps.
- **Non-idempotent steps.** If a step sends an email and retries, the user gets duplicate emails. Use idempotency keys or check state before acting.
- **Storing secrets in event payloads.** Events are logged and visible in the dashboard. Pass IDs and look up sensitive data inside steps.
- **Ignoring step return values.** Steps must return serializable data. Returning database connection objects or class instances will break checkpointing.

## Quick Example

```typescript
// .env.local
INNGEST_EVENT_KEY=your-event-key        // Production event key
INNGEST_SIGNING_KEY=your-signing-key    // Verifies requests from Inngest
// Dev server runs automatically with `npx inngest-cli@latest dev`
```
skilldb get background-jobs-services-skills/InngestFull skill: 240 lines
Paste into your CLAUDE.md or agent config

Inngest

Core Philosophy

Inngest provides durable, event-driven functions that run reliably without infrastructure management. Functions are triggered by events, executed as step functions with automatic retries, and orchestrated through a declarative SDK. The core idea is that background work should be as simple as writing a function — Inngest handles queuing, retries, concurrency, observability, and state management. You ship functions, not infrastructure.

Inngest functions are durable: each step is checkpointed, so if a step fails, execution resumes from the last successful step rather than restarting from scratch. This makes complex multi-step workflows reliable by default.

Setup

Installation and Configuration

// Install dependencies
// npm install inngest

// lib/inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
  // Optional: event schemas for type safety
  schemas: new EventSchemas().fromRecord<{
    "user/signup": { data: { userId: string; email: string } };
    "order/placed": { data: { orderId: string; total: number } };
    "email/send": { data: { to: string; templateId: string; vars: Record<string, string> } };
  }>(),
});

Next.js API Route Handler

// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { onUserSignup } from "@/lib/inngest/functions/on-user-signup";
import { processOrder } from "@/lib/inngest/functions/process-order";
import { dailyReport } from "@/lib/inngest/functions/daily-report";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [onUserSignup, processOrder, dailyReport],
});

Environment Configuration

// .env.local
INNGEST_EVENT_KEY=your-event-key        // Production event key
INNGEST_SIGNING_KEY=your-signing-key    // Verifies requests from Inngest
// Dev server runs automatically with `npx inngest-cli@latest dev`

Key Techniques

Step Functions with Checkpointing

// lib/inngest/functions/on-user-signup.ts
import { inngest } from "../client";

export const onUserSignup = inngest.createFunction(
  {
    id: "on-user-signup",
    retries: 3,
    concurrency: { limit: 10 },
  },
  { event: "user/signup" },
  async ({ event, step }) => {
    // Each step is individually retried and checkpointed
    const user = await step.run("fetch-user", async () => {
      const res = await db.user.findUnique({ where: { id: event.data.userId } });
      if (!res) throw new Error("User not found");
      return res;
    });

    // This step runs only after fetch-user succeeds
    await step.run("send-welcome-email", async () => {
      await emailService.send({
        to: user.email,
        template: "welcome",
        vars: { name: user.name },
      });
    });

    // Sleep pauses execution durably — no compute used while waiting
    await step.sleep("wait-before-onboarding", "3 days");

    await step.run("send-onboarding-tips", async () => {
      await emailService.send({
        to: user.email,
        template: "onboarding-tips",
      });
    });

    return { success: true, userId: user.id };
  }
);

Cron-Triggered Functions

// lib/inngest/functions/daily-report.ts
import { inngest } from "../client";

export const dailyReport = inngest.createFunction(
  { id: "daily-report" },
  { cron: "0 9 * * *" }, // Every day at 9 AM UTC
  async ({ step }) => {
    const stats = await step.run("gather-stats", async () => {
      const [users, orders, revenue] = await Promise.all([
        db.user.count({ where: { createdAt: { gte: yesterday() } } }),
        db.order.count({ where: { createdAt: { gte: yesterday() } } }),
        db.order.aggregate({ _sum: { total: true }, where: { createdAt: { gte: yesterday() } } }),
      ]);
      return { users, orders, revenue: revenue._sum.total ?? 0 };
    });

    await step.run("send-report", async () => {
      await slack.postMessage({
        channel: "#daily-metrics",
        text: `Daily: ${stats.users} signups, ${stats.orders} orders, $${stats.revenue} revenue`,
      });
    });
  }
);

Fan-Out Pattern

// lib/inngest/functions/process-order.ts
import { inngest } from "../client";

export const processOrder = inngest.createFunction(
  { id: "process-order", retries: 5 },
  { event: "order/placed" },
  async ({ event, step }) => {
    const order = await step.run("load-order", async () => {
      return db.order.findUniqueOrThrow({
        where: { id: event.data.orderId },
        include: { items: true, customer: true },
      });
    });

    // Fan-out: send multiple events to trigger parallel downstream functions
    await step.sendEvent("notify-systems", [
      { name: "email/send", data: { to: order.customer.email, templateId: "order-confirm", vars: { orderId: order.id } } },
      { name: "inventory/reserve", data: { items: order.items } },
      { name: "analytics/track", data: { event: "purchase", value: order.total } },
    ]);

    // Wait for external webhook callback using step.waitForEvent
    const shippingUpdate = await step.waitForEvent("wait-for-shipment", {
      event: "shipping/label-created",
      match: "data.orderId",
      timeout: "7d",
    });

    if (shippingUpdate) {
      await step.run("notify-shipped", async () => {
        await emailService.send({
          to: order.customer.email,
          template: "order-shipped",
          vars: { trackingNumber: shippingUpdate.data.trackingNumber },
        });
      });
    }
  }
);

Sending Events from Application Code

// app/api/orders/route.ts
import { inngest } from "@/lib/inngest/client";

export async function POST(req: Request) {
  const body = await req.json();

  const order = await db.order.create({ data: body });

  // Fire-and-forget: Inngest handles the rest
  await inngest.send({
    name: "order/placed",
    data: { orderId: order.id, total: order.total },
  });

  return Response.json({ orderId: order.id });
}

Throttling and Rate Limiting

export const rateLimitedSync = inngest.createFunction(
  {
    id: "sync-to-crm",
    throttle: { limit: 100, period: "1m" }, // Max 100 invocations per minute
    retries: 3,
    concurrency: { limit: 5 },              // Max 5 running at once
  },
  { event: "contact/updated" },
  async ({ event, step }) => {
    await step.run("sync", async () => {
      await crmClient.upsertContact(event.data.contactId);
    });
  }
);

Best Practices

  • Keep steps small and idempotent. Each step can retry independently; side effects should be safe to repeat.
  • Use step.sleep for delays instead of setTimeout. Durable sleep costs nothing and survives restarts.
  • Type your events with EventSchemas to get compile-time safety across your entire event-driven system.
  • Use waitForEvent for human-in-the-loop or external system coordination instead of polling.
  • Set concurrency limits to protect downstream services from being overwhelmed.
  • Return serializable data from steps. Step outputs are persisted as JSON for checkpointing.
  • Use the Inngest dev server during development for local testing with full visibility into function execution.
  • Register all functions in a single serve() endpoint so Inngest can discover them via a single sync.

Anti-Patterns

  • Giant monolithic steps. A single step with 10 API calls means all 10 retry together on failure. Split into individual steps.
  • Non-idempotent steps. If a step sends an email and retries, the user gets duplicate emails. Use idempotency keys or check state before acting.
  • Storing secrets in event payloads. Events are logged and visible in the dashboard. Pass IDs and look up sensitive data inside steps.
  • Ignoring step return values. Steps must return serializable data. Returning database connection objects or class instances will break checkpointing.
  • Using try/catch to swallow errors in steps. Let errors propagate so Inngest can retry. Handle expected failures with conditional logic, not silent catches.
  • Polling in a loop inside a function. Use waitForEvent or step.sleep with retry logic instead of busy-waiting inside a step.

Install this skill directly: skilldb add background-jobs-services-skills

Get CLI access →