Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when building functions that must survive process crashes, retry automatically on failure, run on a schedule, react to events, or maintain state across infrastructure failures — e.g., webhook handlers that drop events, flaky cron jobs, background jobs that fail mid-execution, or workflows that need to resume where they left off. Covers Inngest function configuration, triggers (events, cron, invoke), step execution and memoization, idempotency, cancellation, error handling, retries, logging,
.claude/skills/asymmetric-al-inngest-durable-functions/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-06 | ✗→✓ | ▲ Improved | 166% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 110% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 112% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 169% | 0% |
Master Inngest's durable execution model for building fault-tolerant, long-running workflows. This skill covers the complete lifecycle from triggers to error handling.
> 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.
typescript// ❌ BAD: Non-deterministic logic outside steps async ({ event, step }) => { const timestamp = Date.now(); // This runs multiple times! const result = await step.run("process-data", () => { return processData(event.data); }); }; // ✅ GOOD: All non-deterministic logic in steps async ({ event, step }) => { const result = await step.run("process-with-timestamp", () => { const timestamp = Date.now(); // Only runs once return processData(event.data, timestamp); }); };
Every Inngest function has these hard limits:
step.run() return value)If you're hitting these limits, break your function into smaller functions connected via step.invoke() or step.sendEvent().
Always wrap in step.run():
Never wrap in step.run():
typescriptconst processOrder = inngest.createFunction( { id: "process-order", // Unique, never change this triggers: [{ event: "order/created" }], retries: 4, // Default: 4 retries per step concurrency: 10, // Max concurrent executions }, async ({ event, step }) => { // Your durable workflow }, );
typescript// Step IDs can be reused - Inngest handles counters automatically const data = await step.run("fetch-data", () => fetchUserData()); const more = await step.run("fetch-data", () => fetchOrderData()); // Different execution // Use descriptive IDs for clarity await step.run("validate-payment", () => validatePayment(event.data.paymentId)); await step.run("charge-customer", () => chargeCustomer(event.data)); await step.run("send-confirmation", () => sendEmail(event.data.email));
Triggers are defined in the triggers array in the first argument of createFunction:
typescript// Single event trigger inngest.createFunction( { id: "my-fn", triggers: [{ event: "user/signup" }] }, async ({ event }) => { /* ... */ }, ); // Event with conditional filter inngest.createFunction( { id: "my-fn", triggers: [ { event: "user/action", if: 'event.data.action == "purchase" && event.data.amount > 100', }, ], }, async ({ event }) => { /* ... */ }, ); // Multiple triggers (up to 10) inngest.createFunction( { id: "my-fn", triggers: [ { event: "user/signup" }, { event: "user/login", if: "event.data.firstLogin == true" }, { cron: "0 9 * * *" }, // Daily at 9 AM ], }, async ({ event }) => { /* ... */ }, );
typescript// Basic cron inngest.createFunction( { id: "my-fn", triggers: [{ cron: "0 */6 * * *" }] }, // Every 6 hours async ({ step }) => { /* ... */ }, ); // With timezone inngest.createFunction( { id: "my-fn", triggers: [{ cron: "TZ=Europe/Paris 0 12 * * 5" }] }, // Fridays at noon Paris time async ({ step }) => { /* ... */ }, ); // Combine with events inngest.createFunction( { id: "my-fn", triggers: [ { event: "manual/report.requested" }, { cron: "0 0 * * 0" }, // Weekly on Sunday ], }, async ({ event, step }) => { /* ... */ }, );
typescript// Invoke another function as a step const result = await step.invoke("generate-report", { function: generateReportFunction, data: { userId: event.data.userId }, }); // Use returned data await step.run("process-report", () => { return processReport(result); });
typescript// Prevent duplicate events with custom ID await inngest.send({ id: `checkout-completed-${cartId}`, // 24-hour deduplication name: "cart/checkout.completed", data: { cartId, email: "user@example.com" }, });
typescriptconst sendEmail = inngest.createFunction( { id: "send-checkout-email", triggers: [{ event: "cart/checkout.completed" }], // Only run once per cartId per 24 hours idempotency: "event.data.cartId", }, async ({ event, step }) => { // This function won't run twice for same cartId }, ); // Complex idempotency keys const processUserAction = inngest.createFunction( { id: "process-user-action", triggers: [{ event: "user/action.performed" }], // Unique per user + organization combination idempotency: 'event.data.userId + "-" + event.data.organizationId', }, async ({ event, step }) => { /* ... */ }, );
In expressions, event = the original triggering event, async = the new event being matched. See Expression Syntax Reference for full details.
typescriptconst processOrder = inngest.createFunction( { id: "process-order", triggers: [{ event: "order/created" }], cancelOn: [ { event: "order/cancelled", if: "event.data.orderId == async.data.orderId", }, ], }, async ({ event, step }) => { await step.sleepUntil("wait-for-payment", event.data.paymentDue); // Will be cancelled if order/cancelled event received await step.run("charge-payment", () => processPayment(event.data)); }, );
typescriptconst processWithTimeout = inngest.createFunction( { id: "process-with-timeout", triggers: [{ event: "long/process.requested" }], timeouts: { start: "5m", // Cancel if not started within 5 minutes finish: "30m", // Cancel if not finished within 30 minutes }, }, async ({ event, step }) => { /* ... */ }, );
typescript// Listen for cancellation events const cleanupCancelled = inngest.createFunction( { id: "cleanup-cancelled-process", triggers: [{ event: "inngest/function.cancelled" }], }, async ({ event, step }) => { if (event.data.function_id === "process-order") { await step.run("cleanup-resources", () => { return cleanupOrderResources(event.data.run_id); }); } }, );
typescriptconst reliableFunction = inngest.createFunction( { id: "reliable-function", triggers: [{ event: "critical/task" }], retries: 10, // Up to 10 retries per step }, async ({ event, step, attempt }) => { // `attempt` is the zero-indexed function-level retry counter (`ctx.attempt`). // It counts how many times Inngest has retried this function invocation — not // per-step retries and not a step-local counter. if (attempt > 5) { // Different logic for later function-level attempts } }, );
Prevent retries for code that won't succeed upon retry.
typescriptimport { NonRetriableError } from "inngest"; const processUser = inngest.createFunction( { id: "process-user", triggers: [{ event: "user/process.requested" }] }, async ({ event, step }) => { const user = await step.run("fetch-user", async () => { const user = await db.users.findOne(event.data.userId); if (!user) { // Don't retry - user doesn't exist throw new NonRetriableError("User not found, stopping execution"); } return user; }); // Continue processing... }, );
typescriptimport { RetryAfterError } from "inngest"; const respectRateLimit = inngest.createFunction( { id: "api-call", triggers: [{ event: "api/call.requested" }] }, async ({ event, step }) => { await step.run("call-api", async () => { const response = await externalAPI.call(event.data); if (response.status === 429) { // Retry after specific time from API const retryAfter = response.headers["retry-after"]; throw new RetryAfterError("Rate limited", `${retryAfter}s`); } return response.data; }); }, );
typescriptimport winston from "winston"; // Configure logger const logger = winston.createLogger({ level: "info", format: winston.format.json(), transports: [new winston.transports.Console()], }); const inngest = new Inngest({ id: "my-app", logger, // Pass logger to client }); // Or use the built-in ConsoleLogger for simple log level control import { ConsoleLogger, Inngest } from "inngest"; const inngest = new Inngest({ id: "my-app", logger: new ConsoleLogger({ level: "debug" }), // "debug" | "info" | "warn" | "error" });
⚠️ v4 Breaking Change: The logLevel option has been removed. Use the logger option with ConsoleLogger or a custom logger instead.
typescriptconst processData = inngest.createFunction( { id: "process-data", triggers: [{ event: "data/process.requested" }] }, async ({ event, step, logger }) => { // ✅ GOOD: Log inside steps to avoid duplicates const result = await step.run("fetch-data", async () => { logger.info("Fetching data for user", { userId: event.data.userId }); return await fetchUserData(event.data.userId); }); // ❌ AVOID: Logging outside steps can duplicate // logger.info("Processing complete"); // This could run multiple times! await step.run("log-completion", async () => { logger.info("Processing complete", { resultCount: result.length }); }); }, );
Checkpointing is enabled by default in v4. It allows functions to persist state periodically during execution, reducing latency between steps.
typescript// Checkpointing is enabled by default in v4 // Configure maxRuntime for serverless platforms (set to 60-80% of platform timeout) const realTimeFunction = inngest.createFunction( { id: "real-time-function", triggers: [{ event: "realtime/process" }], checkpointing: { maxRuntime: "50s", // For serverless with 60s timeout }, }, async ({ event, step }) => { // Steps execute immediately with periodic checkpointing const result1 = await step.run("step-1", () => process1(event.data)); const result2 = await step.run("step-2", () => process2(result1)); return { result2 }; }, ); // Disable checkpointing if needed const legacyFunction = inngest.createFunction( { id: "legacy-function", triggers: [{ event: "legacy/process" }], checkpointing: false, }, async ({ event, step }) => { /* ... */ }, );
typescriptconst conditionalProcess = inngest.createFunction( { id: "conditional-process", triggers: [{ event: "process/conditional" }] }, async ({ event, step }) => { const userData = await step.run("fetch-user", () => { return getUserData(event.data.userId); }); // Conditional step execution if (userData.isPremium) { await step.run("premium-processing", () => { return processPremiumFeatures(userData); }); } // Always runs await step.run("standard-processing", () => { return processStandardFeatures(userData); }); }, );
typescriptconst robustProcess = inngest.createFunction( { id: "robust-process", triggers: [{ event: "process/robust" }] }, async ({ event, step }) => { let primaryResult; try { primaryResult = await step.run("primary-service", () => { return callPrimaryService(event.data); }); } catch (error) { // Fallback to secondary service primaryResult = await step.run("fallback-service", () => { return callSecondaryService(event.data); }); } return { result: primaryResult }; }, );
_This skill covers Inngest's durable function patterns. For event sending and webhook handling, see the inngest-events skill._
These upstream Inngest instructions are vendored for agent tooling and integration work in this monorepo.
Use this skill when inngest-durable-functions matches the current Inngest task. If the right skill is unclear, start with docs/ai/skills/inngest/SKILL.md.
integration.
inngest-brownfield-audit before changing existing app workflows orfragile background work.
AGENTS.md, reporulebooks, framework docs, and runtime evidence.
INNGEST_* envrequirements out of agent-tooling-only changes.
or dependencies.
workflow behavior.
port.
docs/ai/skills/inngest/references/upstream.md.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-06 | fail→pass | 11,341 | 3,592 | -68% | 1 | 1 | 0% | 1,864 | 4,961 | +166% | 0 | 0 | — |
case-01 | fail→pass | 15,324 | 11,412 | -26% | 1 | 1 | 0% | 3,234 | 6,785 | +110% | 0 | 0 | — |
case-02 | fail→pass | 20,939 | 12,977 | -38% | 1 | 1 | 0% | 4,266 | 6,804 | +59% | 0 | 0 | — |
case-03 | fail→pass | 15,322 | 12,940 | -16% | 1 | 1 | 0% | 3,337 | 7,086 | +112% | 0 | 0 | — |
case-04 | pass→pass | 8,662 | 6,658 | -23% | 1 | 1 | 0% | 1,679 | 5,506 | +228% | 0 | 0 | — |
case-05 | pass→pass | 14,231 | 7,789 | -45% | 1 | 1 | 0% | 2,593 | 5,833 | +125% | 0 | 0 | — |
case-07 | pass→pass | 11,780 | 3,005 | -74% | 1 | 1 | 0% | 2,114 | 4,923 | +133% | 0 | 0 | — |
case-08 | fail→pass | 11,336 | 4,417 | -61% | 1 | 1 | 0% | 1,879 | 5,052 | +169% | 0 | 0 | — |
case-09 | pass→pass | 8,976 | 6,046 | -33% | 1 | 1 | 0% | 1,700 | 5,584 | +228% | 0 | 0 | — |
case-10 | fail→pass | 22,085 | 6,598 | -70% | 1 | 1 | 0% | 3,333 | 5,440 | +63% | 0 | 0 | — |
case-11 | fail→pass | 5,622 | 3,640 | -35% | 1 | 1 | 0% | 1,049 | 4,937 | +371% | 0 | 0 | — |
case-12 | pass→pass | 8,584 | 4,317 | -50% | 1 | 1 | 0% | 1,400 | 5,123 | +266% | 0 | 0 | — |
case-13 | pass→pass | 9,550 | 5,842 | -39% | 1 | 1 | 0% | 1,726 | 5,361 | +211% | 0 | 0 | — |
case-14 | pass→pass | 8,721 | 4,312 | -51% | 1 | 1 | 0% | 1,603 | 5,118 | +219% | 0 | 0 | — |
case-15 | pass→pass | 17,256 | 7,141 | -59% | 1 | 1 | 0% | 2,627 | 5,630 | +114% | 0 | 0 | — |
case-16 | pass→pass | 9,304 | 4,930 | -47% | 1 | 1 | 0% | 1,700 | 5,234 | +208% | 0 | 0 | — |
case-17 | pass→pass | 8,233 | 5,645 | -31% | 1 | 1 | 0% | 1,660 | 5,393 | +225% | 0 | 0 | — |
case-18 | pass→pass | 9,226 | 4,500 | -51% | 1 | 1 | 0% | 1,670 | 5,171 | +210% | 0 | 0 | — |
case-19 | fail→pass | 13,436 | 6,583 | -51% | 1 | 1 | 0% | 2,333 | 5,594 | +140% | 0 | 0 | — |
case-20 | fail→fail | 15,144 | 10,965 | -28% | 1 | 1 | 0% | 2,674 | 6,454 | +141% | 0 | 0 | — |
case-21 | fail→fail | 10,253 | 6,014 | -41% | 1 | 1 | 0% | 1,972 | 5,458 | +177% | 0 | 0 | — |
case-22 | fail→fail | 14,671 | 12,299 | -16% | 1 | 1 | 0% | 2,917 | 6,798 | +133% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +36 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.