← All posts
·18 min read

Claude Code with Inngest: Durable Background Jobs

Claude CodeInngestWorkflowsBackground Jobs
Claude Code with Inngest: Durable Workflows for Background Jobs

Why Inngest without CLAUDE.md generates functions that lose durability across deploys

Inngest's core promise is durability: a function that crashes midway through is resumed from the last successful step, not restarted from the beginning. That promise depends entirely on async work being wrapped in step.run() calls. Each step.run() is checkpointed. If the process dies after step 2 and before step 3, Inngest replays from step 3. Async work outside a step boundary is not checkpointed and runs again on every retry.

Claude Code does not know this. Without explicit constraints, Claude generates async work directly in the function body, uses setTimeout or await new Promise(resolve => setTimeout(resolve, 5000)) for delays, forgets to add the new function to the serve() functions array, and occasionally generates BullMQ or pg-boss patterns because those are more common in its training data. The result is Inngest functions that look correct but silently lose durability the first time a deploy happens mid-execution or a step throws.

This guide covers the CLAUDE.md configuration that anchors Claude Code to Inngest 3's actual model: all async work inside step.run(), all delays via step.sleep(), all pauses via step.waitForEvent(), and a serve handler that registers every function. If you are deploying Inngest functions on a Next.js app, Claude Code with Next.js covers the project structure conventions that complement this setup. For hosting and deployment, Claude Code with Railway covers the infrastructure layer where Inngest functions run.

The Inngest CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Inngest integration it needs to declare: the Inngest client singleton, the serve handler location, the step boundary rules that enforce durability, the sleep and wait primitives, the event naming convention, retry configuration, idempotency key discipline, and the hard rules that block the patterns Claude generates without guidance.

# Inngest rules

## Stack
- Inngest 3.x, TypeScript 5.x strict
- Next.js 14+ App Router (serve handler at app/api/inngest/route.ts)
- Node.js 20+ runtime

## Client
- ONE client instance exported from lib/inngest.ts
- const inngest = new Inngest({ id: 'my-app' })
- NEVER create multiple Inngest instances, import from lib/inngest.ts everywhere
- NEVER use string literals for the app ID in function files, always import the client

## Function definition
- inngest.createFunction({ id: 'kebab-case-id' }, trigger, async ({ event, step }) => { ... })
- Function IDs are kebab-case, globally unique within the app
- The trigger is either { event: 'domain/action' } or { cron: 'cron expression' }
- ALWAYS add every new function to the serve() functions array in app/api/inngest/route.ts

## Serve handler (app/api/inngest/route.ts)
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { onUserCreated } from '@/inngest/onUserCreated';
import { sendWeeklyDigest } from '@/inngest/sendWeeklyDigest';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [onUserCreated, sendWeeklyDigest],
});

## Step boundary rules (CRITICAL)
- ALL async work MUST be inside a step.run() call, this is the durability boundary
- step.run('step-name', async () => { ... }), name is kebab-case, describes what this step does
- step.run() result is cached on success; if the function retries, completed steps are NOT re-run
- NEVER await async work directly in the function body outside a step.run()
- NEVER call external APIs, send emails, write to the database, or fetch outside step.run()
- Return values from step.run() are the only way to pass data between steps

## Sleep and wait primitives
- step.sleep('wait-name', '1h')         , durable sleep, survives deploys and crashes
- step.sleep('wait-name', '30m')        , supports s, m, h, d suffixes
- step.sleepUntil('wait-name', date)    , durable sleep until a specific Date object
- step.waitForEvent('wait-name', { event: 'domain/action', timeout: '1d' }), pauses until an event arrives
- NEVER use setTimeout or setInterval inside Inngest functions
- NEVER use await new Promise(resolve => setTimeout(resolve, ms))
- NEVER use JS date arithmetic for delays, use step.sleep duration strings

## Event naming convention
- Format: 'domain/action', always lowercase, always a noun/verb pair
- Examples: user/created, order/placed, invoice/paid, subscription/cancelled
- NEVER use generic names like 'event' or 'trigger'
- NEVER use PascalCase or camelCase event names

## Event triggering
- await inngest.send({ name: 'user/created', data: { userId, email } })
- inngest.send() can be called from anywhere: API route, server action, webhook handler
- data is typed, define an EventPayload interface for every event

## Retries
- Default: 4 retries with exponential backoff, sufficient for most cases
- Override: { id: 'my-fn', retries: 10 } for high-stakes functions
- step.run() retries automatically on throw, return normally to mark a step as done
- Throw a NonRetriableError for permanent failures: throw new NonRetriableError('...')
- NEVER catch and suppress errors inside step.run() unless you want no retry

## Idempotency
- inngest.send({ name: '...', data: {}, idempotencyKey: uniqueKey })
- idempotencyKey deduplicates events, same key = same event, second send is a no-op
- Use a deterministic key: order ID, webhook ID, or userId + timestamp
- ALWAYS pass idempotencyKey for webhook-triggered sends to prevent replay duplicates

## Cron schedules
- { cron: '0 9 * * 1' } as the trigger, standard 5-field cron, UTC timezone
- Cron functions receive no event.data, check for undefined before accessing it
- ALWAYS name cron functions by what they do, not when they run: 'send-weekly-digest' not 'monday-9am'

## Hard rules
- NEVER put async work outside step.run(), durability is lost the moment you do
- NEVER use setTimeout or Promise sleep, use step.sleep() always
- NEVER create a function and forget to add it to serve(), it will never register
- NEVER use the same function ID twice, Inngest will silently ignore the duplicate
- NEVER generate Bull, BullMQ, or pg-boss patterns, Inngest is a different model
- NEVER inline the Inngest client, always import from lib/inngest.ts

Three rules here prevent the majority of durability failures Claude generates without them.

The step boundary rule is the single most impactful entry. When Claude writes const user = await db.users.findById(event.data.userId) directly in the function body, that database call reruns on every retry. If the function has already charged a payment card and then crashes while sending a confirmation email, the payment runs again on retry. Wrapping both operations in separate step.run() calls means Inngest caches the payment step result and skips it on retry, running only the email step. Claude cannot infer this from the Inngest API surface alone. The rule makes it explicit.

The step.sleep rule matters because setTimeout inside a serverless function does not survive a cold start. A function that calls await new Promise(resolve => setTimeout(resolve, 60 * 60 * 1000)) to wait one hour will be killed by the platform's function timeout (typically 10 to 30 seconds on Vercel or Railway). step.sleep('wait', '1h') hands the timer to Inngest's scheduler and releases the function execution slot. Inngest resumes the function after one hour. The rule removes the setTimeout option entirely.

The serve() registration rule prevents silent failures. A function that is not in the serve() functions array is never registered with Inngest. inngest.send({ name: 'user/created', ... }) fires, Inngest receives it, and nothing runs. No error is thrown. The event silently drops. Claude adds a function to its file but does not always update the serve handler because it does not see the connection. The rule makes registration mandatory.

Inngest client setup and serve handler mounting

The Inngest client is a singleton. Every function file imports it from the same location. Multiple client instances with the same app ID cause duplicate function registrations and unpredictable behaviour.

Create lib/inngest.ts once:

import { Inngest } from 'inngest';

export const inngest = new Inngest({ id: 'my-app' });

The serve handler in Next.js App Router lives at app/api/inngest/route.ts. It exports GET, POST, and PUT handlers that Inngest uses for handshake, event delivery, and function sync respectively.

Add the serve handler section to CLAUDE.md:

## Serve handler maintenance

- Location: app/api/inngest/route.ts
- Import pattern: named import for each function, then add to functions array
- EVERY new function must be imported here AND added to the functions array
- The serve() call is the registration contract, if it is not here, Inngest does not know it exists

## Adding a new function checklist
1. Create inngest/{functionName}.ts
2. Import the inngest client from @/lib/inngest
3. Define and export the function with inngest.createFunction(...)
4. Open app/api/inngest/route.ts
5. Import the new function
6. Add it to the functions array in serve()
7. Verify locally: npx inngest-cli@latest dev should list the function

Claude reliably follows a checklist in CLAUDE.md when it is explicit. Without this checklist, step 4 through 6 are frequently skipped.

step.run vs step.sleep vs step.waitForEvent durable primitives

These three primitives cover the full range of durable execution patterns. Understanding which to use in which situation prevents the most common Inngest design mistakes.

step.run('name', async () => { ... }) is the unit of durable work. Use it for every operation that has a side effect: database writes, API calls, email sends, third-party webhooks. The return value is serialised and cached. On retry, Inngest skips completed steps and injects their cached return values.

const onUserCreated = inngest.createFunction(
  { id: 'on-user-created', retries: 4 },
  { event: 'user/created' },
  async ({ event, step }) => {
    // Step 1, database write. If this completes, it will not run again on retry.
    const user = await step.run('create-profile', async () => {
      return await db.profiles.create({ userId: event.data.userId });
    });

    // Step 2, email send. Uses the return value from step 1.
    await step.run('send-welcome-email', async () => {
      await resend.emails.send({
        to: event.data.email,
        subject: 'Welcome',
        html: welcomeTemplate({ name: user.displayName }),
      });
    });

    // Step 3, CRM sync. Independent of step 2, runs after it regardless.
    await step.run('sync-to-crm', async () => {
      await crm.contacts.upsert({ email: event.data.email, userId: event.data.userId });
    });
  }
);

step.sleep('name', duration) pauses execution durably. The function execution slot is released and reclaimed when the duration expires. Use it for delays that would otherwise exceed the platform's function timeout.

step.waitForEvent('name', { event: 'domain/action', timeout: 'duration' }) pauses until a matching event arrives. This enables human-in-the-loop patterns where a background job waits for user action before continuing.

Add a primitives reference section to CLAUDE.md:

## step primitives quick reference

### step.run, durable side-effect
const result = await step.run('step-name', async () => {
  const data = await externalApi.fetch();
  return data;  // serialised and cached
});
// result is available in subsequent steps

### step.sleep, durable delay
await step.sleep('cool-down', '24h');  // s, m, h, d

### step.sleepUntil, durable wait until date
const renewalDate = new Date(event.data.renewsAt);
await step.sleepUntil('wait-for-renewal', renewalDate);

### step.waitForEvent, pause until event
const approval = await step.waitForEvent('wait-for-approval', {
  event: 'invoice/approved',
  timeout: '7d',  // returns null if timeout expires without event
});

if (!approval) {
  await step.run('send-overdue-notice', async () => {
    await sendOverdueNotice(event.data.invoiceId);
  });
  return;
}

await step.run('process-approved-invoice', async () => {
  await processInvoice(event.data.invoiceId);
});

## Rules
- step.run return value is the only safe way to pass data between steps
- step.sleep duration: '30s', '5m', '2h', '3d', no weeks, no months
- step.waitForEvent returns null on timeout, always check before continuing
- NEVER chain .then() on step primitives, always await them

The step.waitForEvent null check is the pattern Claude most often omits. When the timeout expires and no event arrives, waitForEvent returns null. Code that proceeds without checking null will attempt to process an undefined invoice ID, produce a confusing error, and retry. The explicit null guard in the template teaches Claude to generate it.

Event triggering with inngest.send

Events are the seams of an Inngest application. One part of the codebase fires an event; Inngest delivers it to every function that listens for it. This decoupling means an API route does not import the function that handles user creation. It only imports the Inngest client.

Define a typed event map to get full TypeScript coverage across the event boundary:

// lib/inngest.ts
import { Inngest } from 'inngest';

type Events = {
  'user/created': { data: { userId: string; email: string } };
  'order/placed': { data: { orderId: string; total: number; userId: string } };
  'subscription/cancelled': { data: { subscriptionId: string; userId: string; reason: string } };
};

export const inngest = new Inngest({ id: 'my-app' });

Add the event typing section to CLAUDE.md:

## Event typing

- Define all events in the Inngest({ id }) call's type parameter
- Type format: { 'domain/action': { data: { field: type } } }
- event.data in function bodies is fully typed from this definition
- NEVER use event.data.anyField without it being in the type definition
- inngest.send() is type-checked, wrong event name or data shape is a compile error

## Sending events
// From an API route
await inngest.send({
  name: 'user/created',
  data: { userId: newUser.id, email: newUser.email },
});

// With idempotency key (webhook handlers, payment callbacks)
await inngest.send({
  name: 'order/placed',
  data: { orderId: order.id, total: order.total, userId: order.userId },
  idempotencyKey: `stripe-webhook-${webhookEvent.id}`,
});

// Multiple events in one call (batch)
await inngest.send([
  { name: 'order/placed', data: { orderId: 'ORD-1', total: 99, userId: 'USR-1' } },
  { name: 'order/placed', data: { orderId: 'ORD-2', total: 149, userId: 'USR-2' } },
]);

Inngest batch sends are one event trigger per call to inngest.send(), where the array overload fires all events atomically. This matters when you are triggering fan-out from a webhook that delivers multiple records at once. Claude Code with Stripe covers webhook handler patterns that pair naturally with this batch send approach.

Retries and idempotency keys

Inngest retries failed steps with exponential backoff. The default is 4 retries. Most production functions should configure this explicitly rather than relying on the default.

Add a retry section to CLAUDE.md:

## Retry configuration

- Default is 4 retries, set explicitly on every function for clarity
- High-stakes functions (payments, emails): retries: 10
- Idempotent read-only functions: retries: 2
- Functions that must not retry: retries: 0

## NonRetriableError, permanent failures
import { NonRetriableError } from 'inngest';

await step.run('charge-card', async () => {
  const result = await stripe.paymentIntents.create({ ... });
  if (result.status === 'requires_action') {
    throw new NonRetriableError('Card requires 3DS, cannot auto-retry');
  }
  return result;
});

// Any other thrown error will retry up to the configured limit
// NonRetriableError skips all remaining retries and marks the function run as failed

## Idempotency key patterns
- Stripe webhook: idempotencyKey: `stripe-${webhookEvent.id}`
- Sendgrid bounce: idempotencyKey: `sg-bounce-${event.email}-${event.timestamp}`
- Scheduled job that should not double-fire: idempotencyKey: `weekly-digest-${weekStart}`
- NEVER use Math.random() or Date.now() as an idempotency key
- ALWAYS use a deterministic, source-derived value

## Rules
- step.run throws are automatically retried, do not catch-and-suppress inside step.run
- Return a value from step.run to signal success, any throw signals failure and triggers retry
- Permanent errors (invalid card, user not found, resource deleted) get NonRetriableError
- Transient errors (network timeout, rate limit, service unavailable) get normal retries

The NonRetriableError distinction is important. A card requiring 3DS authentication will never succeed on retry because the user needs to complete the 3DS flow first. Retrying it 10 times wastes API calls and produces misleading error logs. A temporary network timeout to Stripe should retry because the next attempt will likely succeed. Claude generates uniform error handling without this distinction in CLAUDE.md.

Scheduled functions with cron

Cron functions run on a schedule. They receive no external event and have access to the same step primitives as event-driven functions.

Add a cron section to CLAUDE.md:

## Cron functions

## Trigger format
{ cron: 'minute hour day-of-month month day-of-week' }
{ cron: '0 9 * * 1' }   , every Monday at 09:00 UTC
{ cron: '0 0 1 * *' }   , first day of every month at 00:00 UTC
{ cron: '*/15 * * * *' }, every 15 minutes

## Cron function structure
import { inngest } from '@/lib/inngest';
import { db } from '@/lib/db';

export const sendWeeklyDigest = inngest.createFunction(
  { id: 'send-weekly-digest', retries: 2 },
  { cron: '0 9 * * 1' },
  async ({ step }) => {
    // No event.data for cron functions, fetch what you need inside step.run
    const activeUsers = await step.run('fetch-active-users', async () => {
      return await db.users.findMany({ where: { active: true } });
    });

    // Fan-out: one send per user
    const events = activeUsers.map((user) => ({
      name: 'digest/send' as const,
      data: { userId: user.id, email: user.email },
    }));

    await step.run('trigger-digest-sends', async () => {
      await inngest.send(events);
    });
  }
);

## Rules
- Cron functions NEVER have event.data, do not access it
- ALWAYS wrap data fetching in step.run even in cron functions
- Fan-out inside a cron function: fetch IDs, then inngest.send() an array of per-item events
- Name cron functions by purpose, not schedule: 'send-weekly-digest' not 'monday-cron'
- Add cron functions to serve() the same as event-driven functions

The cron-to-fan-out pattern (fetch user IDs in a cron, then send one event per user) is the recommended Inngest pattern for scheduled batch operations. It keeps individual function runs small and independent. A failure sending one user's digest retries only that user, not the entire batch. Claude generates a loop inside the cron function that processes all users sequentially without the fan-out pattern if the CLAUDE.md does not show it.

Fan-out patterns

Fan-out sends one event per unit of work and lets Inngest parallelise the handlers. It is the Inngest alternative to a worker queue with N concurrent workers.

Add a fan-out section to CLAUDE.md:

## Fan-out pattern

## When to use fan-out
- Processing a list of items where each item is independent
- Sending N emails, N notifications, N API calls
- Parallelising work that would time out if done serially in one function

## Fan-out structure
// Orchestrator function: fetches IDs, fires one event per item
export const processAllOrders = inngest.createFunction(
  { id: 'process-all-orders', retries: 2 },
  { event: 'orders/process-batch' },
  async ({ event, step }) => {
    const orderIds = await step.run('fetch-pending-orders', async () => {
      return await db.orders.findMany({
        where: { status: 'pending' },
        select: { id: true },
      });
    });

    await step.run('fan-out-orders', async () => {
      await inngest.send(
        orderIds.map((order) => ({
          name: 'order/process' as const,
          data: { orderId: order.id },
        }))
      );
    });
  }
);

// Worker function: handles one item
export const processOrder = inngest.createFunction(
  { id: 'process-order', retries: 5 },
  { event: 'order/process' },
  async ({ event, step }) => {
    await step.run('validate-order', async () => {
      return await validateOrder(event.data.orderId);
    });

    await step.run('charge-payment', async () => {
      return await chargePayment(event.data.orderId);
    });

    await step.run('send-confirmation', async () => {
      await sendOrderConfirmation(event.data.orderId);
    });
  }
);

## Rules
- ALWAYS wrap the inngest.send() fan-out call in step.run() so it is idempotent on retry
- Keep the orchestrator function thin, fetch IDs, send events, done
- Keep worker functions focused, one item, complete lifecycle
- Worker functions can have higher retries than the orchestrator
- NEVER process items in a loop inside one function if N > 10, use fan-out

The step.run() wrapper around inngest.send() inside the orchestrator is easy to miss. If the orchestrator sends 100 events and then crashes before returning, Inngest retries the orchestrator. Without the step.run() wrapper, it sends 100 more events. With the wrapper, Inngest sees that the fan-out-orders step already completed and skips it on retry. The 100 events are sent exactly once.

Local development with Inngest Dev Server

Inngest provides a local development server that picks up your serve handler, receives events you send, and runs your functions locally. No Inngest account required for local dev.

Add a local dev section to CLAUDE.md:

## Local development

## Starting the dev server
npx inngest-cli@latest dev -u http://localhost:3000/api/inngest

# Or with a custom port
npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --port 8288

## What the dev server does
- Connects to your serve handler at the given URL
- Lists all registered functions (verify your serve() exports are correct here)
- Receives inngest.send() calls from your local app
- Runs function handlers locally, showing step-by-step progress
- Retries failed steps automatically, same as production

## Verifying a new function is registered
1. Start Next.js: npm run dev
2. Start Inngest dev server: npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
3. Open http://localhost:8288 in browser
4. Check the Functions tab, your new function should appear
5. If missing: check that it is imported and in the functions array in route.ts

## Sending a test event locally
// From a test script or Next.js server action
await inngest.send({ name: 'user/created', data: { userId: 'test-1', email: 'test@example.com' } });
// Watch the Inngest dev server UI for the function run

## Rules
- ALWAYS start the Inngest dev server alongside the app server during development
- NEVER test Inngest functions by running the function handler directly, trigger via events
- Use the dev server Functions tab to confirm serve() registration before writing tests
- inngest.send() from a test script works the same as from a production API route

The verification step is the one Claude skips most often: developers write a new function, send an event, and see nothing happen because the function was never added to serve(). The dev server's Functions tab makes the registration state visible. Adding the verification step to CLAUDE.md means Claude includes the instruction to check it.

Common Claude Code gotchas with Inngest

Even with the CLAUDE.md template in place, four patterns warrant manual review after Claude generates Inngest code.

Async work outside step boundaries. Scan every function body for await calls that are not inside a step.run(). Expressions like const user = await db.users.find(...) at the top of the function body before any step.run() call lose durability. They are easy to miss because they look correct. They will cause double-execution on any retry after they complete.

Missing function in serve(). After Claude creates a new function file, open app/api/inngest/route.ts and confirm the function is imported and listed in the functions array. Claude reliably creates the file and often skips updating the serve handler, especially if it is in a different file.

Timeout on step.waitForEvent. When waitForEvent returns null (timeout expired), the function run continues with a null value. Check every waitForEvent call for a null guard and a defined fallback path. Claude generates the waitForEvent call correctly but frequently omits the if (!result) { ... return; } branch.

Wrong event name on fan-out. The orchestrator sends { name: 'order/process' } and the worker listens for { event: 'order/process' }. A typo in either (or a mismatch between singular and plural) means events are sent but no function runs. Verify that the event name in inngest.send() exactly matches the event name in the worker's trigger. Claude can drift on naming, especially if the orchestrator and worker are generated in separate prompts. Claude Code with Vercel covers deployment verification patterns that catch registration mismatches before they reach production.

Building durable workflows by default

The Inngest CLAUDE.md in this guide produces functions where all async work is inside step.run() boundaries, delays use step.sleep() that survive deploys and cold starts, pauses use step.waitForEvent() that release execution slots, events carry typed payloads, retries are configured explicitly, idempotency keys prevent duplicate processing on webhook replays, cron functions fan out to per-item event-driven workers, and local development uses the Inngest dev server for visible step-by-step feedback.

The underlying principle is the same across all framework integrations with Claude Code. An Inngest project without a CLAUDE.md produces functions with silent durability bugs: async work that double-executes on retry, delays that break on cold start, and functions that never register because serve() was not updated. A project with the configuration above has one remaining failure mode: the application logic is wrong. Every failed step is a real signal about what the code is doing, not an artifact of how it is structured.

For the mechanics of how CLAUDE.md is read at session start and how to structure multi-file projects, see CLAUDE.md explained. Claudify includes an Inngest-specific CLAUDE.md template pre-configured with the step boundary rules, sleep primitives, event typing, fan-out patterns, and serve handler checklist shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame