Deferred cleanup and rollbacks
Register background cleanup when you create the mess, cancel it if everything succeeds, and keep your critical path fast.
Multi-step functions create resources along the way. An order gets placed, a payment gets charged, inventory gets allocated. If a later step fails, the earlier resources need to be cleaned up.
The traditional approach is onFailure, but that handler only sees the original input and the error. It doesn't know which steps succeeded or what state they left behind. You end up querying external systems to figure out what to undo.
With deferred functions, you register cleanup at the point where you know what needs cleaning up. The deferred run fires after the parent completes. If everything succeeds, you cancel the cleanup before it runs.
§How this works
Define a deferred function for each type of cleanup. Register it right after the step that creates the thing you might need to undo.
01import { createDefer } from "inngest/experimental";02import { z } from "zod";0304const refundPayment = createDefer(inngest, {05 id: "refund-payment",06 schema: z.object({ chargeId: z.string(), amount: z.number() }),07}, async ({ event, step }) => {08 await step.run("issue-refund", async () => {09 await stripe.refunds.create({10 charge: event.data.chargeId,11 amount: event.data.amount,12 });13 });14});1516const releaseInventory = createDefer(inngest, {17 id: "release-inventory",18 schema: z.object({ sku: z.string(), quantity: z.number() }),19}, async ({ event, step }) => {20 await step.run("release-stock", async () => {21 await inventory.release(event.data.sku, event.data.quantity);22 });23});Register them alongside your functions:
01serve({02 client: inngest,03 functions: [processOrder, refundPayment, releaseInventory],04});In your main function, register cleanup after each step. Cancel all of them at the end if the order succeeds.
01const processOrder = inngest.createFunction(02 { id: "process-order", triggers: { event: "order/placed" } },03 async ({ event, step, defer }) => {04 const charge = await step.run("charge-card", async () => {05 return await stripe.charges.create({06 amount: event.data.total,07 currency: "usd",08 source: event.data.paymentSource,09 });10 });1112 // Register refund cleanup. Fires after the parent run ends.13 const refundHandle = defer("refund-if-failed", {14 function: refundPayment,15 data: { chargeId: charge.id, amount: charge.amount },16 });1718 const allocation = await step.run("allocate-inventory", async () => {19 return await inventory.allocate(20 event.data.sku,21 event.data.quantity,22 );23 });2425 // Register inventory cleanup.26 const inventoryHandle = defer("release-if-failed", {27 function: releaseInventory,28 data: { sku: event.data.sku, quantity: event.data.quantity },29 });3031 await step.run("create-shipment", async () => {32 return await shipping.createLabel(event.data.address, allocation);33 });3435 // Everything succeeded. Cancel both cleanups.36 refundHandle.cancel();37 inventoryHandle.cancel();3839 return { status: "shipped", chargeId: charge.id };40 }41);If create-shipment fails after retries are exhausted, the parent run ends in a failed state. Both deferred functions fire: the refund goes out and the inventory is released. If everything succeeds, both are cancelled and never run.
Each deferred function is its own independent run with its own retries. A failed refund doesn't block the inventory release. You see both in the trace view linked to the parent run.
§Alternative approaches
Without deferred functions, you have a few options:
onFailurehandler. Receives the error and the original event, but not intermediate step outputs. You have to re-derive what was charged and what was allocated from external systems.- Try/catch/finally in the function. Works, but the cleanup runs in the same function execution. If the cleanup itself fails, the whole run fails and retries from the beginning.
- Send events to separate functions. Achieves the same decoupling, but you lose the typed schema and the automatic parent/child linking in the UI. You also have to manually track which events to send on failure.
Deferred functions give you the decoupling of separate functions with the co-location and typed data of inline code. The cleanup logic lives next to the code that created the resource, and each cleanup run is independently retried.