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

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.

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

typescript
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 });
13
14 await step.run("send-reply", async () => {
15 await supportPlatform.reply(event.data.ticketId, response.text);
16 });
17
18 // 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 });
28
29 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 });
37
38 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.

§Additional resources