Post-completion side effects
Run analytics, notifications, and logging after your function finishes without blocking the critical path or losing context.
Your function does the important work. After it finishes, you need to do secondary things: log token usage, send a Slack summary, update a CRM record, track analytics. None of that should block the user-facing response or affect the parent run's success status.
Today you either do this before the return (blocking) or send events to separate functions (losing easy access to the data your function just produced). Deferred functions let you register side effects inline, keep them typed and linked to the parent, and run them independently after the parent completes.
§How this works
Define deferred functions for each side effect. Each one receives a typed payload from the parent.
01import { createDefer } from "inngest/experimental";02import { z } from "zod";0304const logUsage = createDefer(inngest, {05 id: "log-ai-usage",06 schema: z.object({07 model: z.string(),08 promptTokens: z.number(),09 completionTokens: z.number(),10 runId: z.string(),11 }),12}, async ({ event, step }) => {13 await step.run("record-usage", async () => {14 await analytics.track("ai.tokens.used", {15 model: event.data.model,16 prompt_tokens: event.data.promptTokens,17 completion_tokens: event.data.completionTokens,18 inngest_run_id: event.data.runId,19 });20 });21});2223const notifySlack = createDefer(inngest, {24 id: "notify-slack-completion",25 schema: z.object({26 channel: z.string(),27 summary: z.string(),28 ticketId: z.string(),29 }),30}, async ({ event, step }) => {31 await step.run("post-message", async () => {32 await slack.chat.postMessage({33 channel: event.data.channel,34 text: `Ticket ${event.data.ticketId} resolved: ${event.data.summary}`,35 });36 });37});In the parent function, call defer() with the data each side effect needs. The parent returns immediately. The deferred runs fire after it finalizes.
01const handleTicket = inngest.createFunction(02 { id: "handle-support-ticket", triggers: { event: "support/ticket.created" } },03 async ({ event, step, defer }) => {04 const response = await step.run("generate-response", async () => {05 return await llm.chat({06 model: "gpt-4o",07 messages: [08 { role: "system", content: "You are a support agent." },09 { role: "user", content: event.data.content },10 ],11 });12 });1314 await step.run("send-reply", async () => {15 await supportPlatform.reply(event.data.ticketId, response.text);16 });1718 // Side effects. None of these block the response.19 defer("log-tokens", {20 function: logUsage,21 data: {22 model: response.model,23 promptTokens: response.usage.prompt_tokens,24 completionTokens: response.usage.completion_tokens,25 runId: event.data.runId,26 },27 });2829 defer("notify-team", {30 function: notifySlack,31 data: {32 channel: "#support-resolved",33 summary: response.text.slice(0, 200),34 ticketId: event.data.ticketId,35 },36 });3738 return { ticketId: event.data.ticketId, status: "resolved" };39 }40);The parent run completes and returns resolved. The token logging and Slack notification run as separate functions with their own retries. If Slack is down, the notification retries without affecting the parent. If analytics fails, the customer still got their response.
§Why not just add more steps?
Adding step.run("log-usage", ...) at the end of the function works, but it means:
- The function stays running longer than it needs to.
- A failure in analytics logging fails the whole run and triggers retries of the entire function.
- The parent run's success status depends on secondary work that the user doesn't care about.
Deferred functions keep the parent run clean. It succeeds or fails based on the work that matters. The side effects run on their own timeline.
§Alternative approaches
- Send events to trigger separate functions. Works, but you lose the typed schema contract and the parent/child linking in traces. You also need to serialize all the data into the event payload manually.
- Fire-and-forget HTTP calls. No retries, no observability, no way to know if they succeeded.
- External tools (Segment, Datadog agents). Fine for generic telemetry, but you lose the run-level context that makes Inngest traces useful.