TypeScript SDK v4 is now available! See what's new
Durable Workflows

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.

typescript
01import { createDefer } from "inngest/experimental";
02import { z } from "zod";
03
04const 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});
15
16const 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:

typescript
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.

typescript
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 });
11
12 // 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 });
17
18 const allocation = await step.run("allocate-inventory", async () => {
19 return await inventory.allocate(
20 event.data.sku,
21 event.data.quantity,
22 );
23 });
24
25 // Register inventory cleanup.
26 const inventoryHandle = defer("release-if-failed", {
27 function: releaseInventory,
28 data: { sku: event.data.sku, quantity: event.data.quantity },
29 });
30
31 await step.run("create-shipment", async () => {
32 return await shipping.createLabel(event.data.address, allocation);
33 });
34
35 // Everything succeeded. Cancel both cleanups.
36 refundHandle.cancel();
37 inventoryHandle.cancel();
38
39 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:

  • onFailure handler. 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.

§Additional resources