Fan-Out: Triggering Multiple Tasks from One Event
There's a moment in every growing application when you look at a single event handler — say, the function that runs when a user signs up — and realise it's doing way too much. Send welcome email. Create billing trial. Add to CRM. Notify Slack. Post to analytics. Log to audit trail.
All of that logic piled into one function, in one file, tested together, deployed together, failing together.
This is where the fan-out pattern comes in. One event fires. Many independent functions react. Each runs in parallel, retries independently, fails independently, and can be modified without touching any of the others.
It's one of the most useful architectural patterns in event-driven design — and in Inngest, it's almost embarrassingly easy to implement.
Quick Reference
What fan-out is: One event triggers multiple independent functions simultaneously.
How Inngest implements it: Any number of functions can register the same event as their trigger. When that event fires, all matching functions run in parallel automatically.
Three variants:
- Static fan-out — multiple functions all listen to the same event trigger
- Dynamic fan-out — one function loads a list, sends one event per item, spawning one function per item
- Chained fan-out — a function fires a new event mid-workflow, triggering another set of functions downstream
Key distinction from parallel steps: Parallel steps share a function's lifetime and state. Fan-out functions are completely independent — separate retry budgets, separate run histories, separate deployable units.
What You Need to Know First
Required reading (in order):
- Event-Driven Architecture: Why Your App Needs It
- Events, Queues, and Workers: The Building Blocks
- Inngest: What It Is and How It Fits In
- Your First Inngest Function
- Steps: Breaking Work into Durable Units
You should understand:
- How
inngest.send()fires an event - How
step.run()wraps durable work - How
step.sendEvent()sends events from within a running function
What We'll Cover in This Article
By the end of this guide, you'll understand:
- What fan-out is and why it matters architecturally
- How Inngest implements static fan-out with multiple trigger registrations
- How dynamic fan-out works with
step.sendEvent()and arrays of events - How chained fan-out lets functions emit downstream events mid-workflow
- When to use fan-out vs. parallel steps (
Promise.all) - How to add new fan-out consumers without modifying existing code
- Concurrency controls for large-scale fan-out
What We'll Explain Along the Way
- The difference between
step.sendEvent()andinngest.send()inside a function - Batch event sending (sending an array of events in one call)
- The 512 KB batch size limit and how to work around it
Part 1: The Problem Fan-Out Solves
Let's start with a concrete "before" picture. Here's a common anti-pattern: one function that handles everything triggered by a user signup.
// ❌ The monolithic signup handler — does too much
export const handleUserSignup = inngest.createFunction(
{ id: "handle-user-signup" },
{ event: "user/account.created" },
async ({ event, step }) => {
await step.run("send-welcome-email", async () => {
await emailService.sendWelcome(event.data.email);
});
await step.run("create-billing-trial", async () => {
await stripe.trials.create(event.data.userId);
});
await step.run("add-to-crm", async () => {
await hubspot.contacts.create(event.data);
});
await step.run("notify-slack", async () => {
await slack.post(`New signup: ${event.data.email}`);
});
await step.run("add-to-mailing-list", async () => {
await mailchimp.lists.addMember(event.data.email);
});
},
);
You can make this more efficient with parallel steps. But there's a deeper structural problem that parallel steps don't fix:
All this logic is coupled. If the Mailchimp integration is removed, you edit this file. If the Slack notification message changes, you edit this file. If you add a new step for a new integration, you edit this file and re-deploy everything. The welcome email and the billing trial are now deployed as one unit even though they have nothing to do with each other.
And if the Slack notification starts failing and consuming retries, it affects the run timeline of everything else in this function. You can see it in the logs, sharing space with the billing and email entries.
Fan-out separates the concerns entirely. Each integration becomes its own independent function. The signup route doesn't know or care how many functions are listening — it just fires the event and moves on.
Part 2: Static Fan-Out — Multiple Functions, One Event
The simplest form of fan-out in Inngest requires no special API. You just define multiple functions with the same event trigger.
As Inngest's fan-out documentation explains: "Since Inngest is powered by events, implementing fan-out is as straightforward as defining multiple functions that use the same event trigger."
// ── The event producer (your API route) ────────────────────────────────────
export async function POST(request: Request) {
const { email, name, password } = await request.json();
const user = await createUser({ email, name, password });
// Fire once — all registered consumers receive it
await inngest.send({
name: "user/account.created",
data: {
userId: user.id,
email: user.email,
name: user.name,
plan: "free",
signedUpAt: new Date().toISOString(),
},
});
return Response.json({ userId: user.id });
}
// ── Consumer 1: Welcome email ───────────────────────────────────────────────
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/account.created" }, // ← same event
async ({ event, step }) => {
await step.run("send-email", async () => {
await emailService.sendWelcome({
to: event.data.email,
name: event.data.name,
});
});
},
);
// ── Consumer 2: Billing trial ───────────────────────────────────────────────
export const createBillingTrial = inngest.createFunction(
{ id: "create-billing-trial" },
{ event: "user/account.created" }, // ← same event
async ({ event, step }) => {
const customer = await step.run("create-stripe-customer", async () => {
return await stripe.customers.create({
email: event.data.email,
metadata: { userId: event.data.userId },
});
});
await step.run("start-trial", async () => {
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: process.env.STRIPE_TRIAL_PRICE_ID }],
trial_period_days: 14,
});
});
},
);
// ── Consumer 3: CRM record ──────────────────────────────────────────────────
export const addToCRM = inngest.createFunction(
{ id: "add-user-to-crm" },
{ event: "user/account.created" }, // ← same event
async ({ event, step }) => {
await step.run("create-hubspot-contact", async () => {
await hubspot.contacts.create({
email: event.data.email,
firstname: event.data.name.split(" ")[0],
properties: {
plan: event.data.plan,
signup_date: event.data.signedUpAt,
},
});
});
},
);
// ── Consumer 4: Team notification ──────────────────────────────────────────
export const notifyTeam = inngest.createFunction(
{ id: "notify-team-slack" },
{ event: "user/account.created" }, // ← same event
async ({ event, step }) => {
await step.run("post-to-slack", async () => {
await slack.chat.postMessage({
channel: "#signups",
text: `🎉 New signup: ${event.data.name} (${event.data.email})`,
});
});
},
);
When user/account.created fires, Inngest dispatches all four functions simultaneously. They run in parallel with complete independence. Notice what this buys you:
Independent failures. If Hubspot is down and add-user-to-crm fails repeatedly, it exhausts its own retry budget. The welcome email and billing trial complete successfully without being affected.
Independent deployment. If you want to update the Slack message format, you modify and deploy only notify-team-slack. The other three functions are untouched.
Independent testing. You can invoke create-billing-trial from the Dev Server in isolation, without triggering the email or the CRM update.
Zero modification to add a new consumer. Want to add a fifth consumer that posts to a data warehouse? Create a new function listening to user/account.created. You don't touch the signup route or any existing function.
Don't forget to register them
Every function must be added to your serve() handler or Inngest won't know it exists:
// src/app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import {
sendWelcomeEmail,
createBillingTrial,
addToCRM,
notifyTeam,
} from "@/inngest/functions/user-signup";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [
sendWelcomeEmail,
createBillingTrial,
addToCRM,
notifyTeam,
// add new consumers here as you create them
],
});
Part 3: Dynamic Fan-Out — One Event Spawns Many
Static fan-out is one event triggering a fixed set of functions. Dynamic fan-out is different: one function loads a list of items and sends one event per item, spawning a separate function execution for each.
This is the pattern for batch processing. Imagine you want to send a weekly digest email to every user. You have 50,000 users. You could write a loop inside one function — but that function would run for potentially hours, holds all state in one place, and a single failure in the 40,000th user could cause all 50,000 to restart.
With dynamic fan-out:
// ── The orchestrator: loads the list, fires one event per item ─────────────
export const weeklyDigestOrchestrator = inngest.createFunction(
{ id: "weekly-digest-orchestrator" },
{ cron: "0 9 * * MON" }, // Every Monday at 9am
async ({ step }) => {
// Step 1: Load all users who should receive the digest
const users = await step.run("fetch-digest-recipients", async () => {
return await db.users
.where({ weeklyDigestEnabled: true, status: "active" })
.select(["id", "email", "name", "timezone"])
.all();
});
// Step 2: Send one event per user — spawns one function run per user
// step.sendEvent() accepts an array — it's a single checkpointed step
await step.sendEvent(
"fan-out-weekly-digests",
users.map((user) => ({
name: "digest/weekly.send",
data: {
userId: user.id,
email: user.email,
name: user.name,
timezone: user.timezone,
weekOf: new Date().toISOString(),
},
})),
);
return { dispatched: users.length };
},
);
// ── The worker: handles one user's digest ──────────────────────────────────
export const sendWeeklyDigest = inngest.createFunction(
{
id: "send-weekly-digest",
// Limit to 50 concurrent runs so we don't overwhelm the email service
concurrency: { limit: 50 },
},
{ event: "digest/weekly.send" },
async ({ event, step }) => {
const digestContent = await step.run("build-digest-content", async () => {
return await digestBuilder.buildForUser(
event.data.userId,
event.data.weekOf,
);
});
await step.run("send-digest-email", async () => {
await emailService.send({
to: event.data.email,
subject: `Your weekly digest — ${event.data.weekOf}`,
html: digestContent.html,
});
});
return { userId: event.data.userId, status: "sent" };
},
);
With 50,000 users, this spawns 50,000 independent function runs. Each has its own:
- Retry budget — if user #23,451's email fails, only that run retries
- Run history — you can inspect any individual user's execution in the Dev Server
- Concurrency slot — controlled by the
concurrency: { limit: 50 }setting
This is dramatically more observable and resilient than a 50,000-iteration loop inside a single function.
The step.sendEvent() batch limit
step.sendEvent() accepts an array of events and sends them all in a single checkpointed step. There is a 512 KB total payload limit for the batch. If your user list is very large or each event carries significant data, you may need to chunk the array:
// Chunking a large list to stay within the 512 KB batch limit
const BATCH_SIZE = 500; // adjust based on your event payload size
const chunks = [];
for (let i = 0; i < users.length; i += BATCH_SIZE) {
chunks.push(users.slice(i, i + BATCH_SIZE));
}
// Send each chunk as a separate step
for (let i = 0; i < chunks.length; i++) {
await step.sendEvent(
`fan-out-batch-${i}`,
chunks[i].map((user) => ({
name: "digest/weekly.send",
data: { userId: user.id, email: user.email },
})),
);
}
Why step.sendEvent() and not inngest.send() inside a function?
You might wonder why we use step.sendEvent() here instead of calling inngest.send() directly. This is an important distinction.
inngest.send() called from inside a function handler is not a step — it's not checkpointed. If the function fails and retries after the send, inngest.send() would fire the events again, potentially spawning duplicate function runs for every user.
step.sendEvent() is checkpointed. If the function fails after the send step completes, the events are memoised — they won't be sent again on retry. Use step.sendEvent() whenever you're sending events from inside a function.
// ❌ Dangerous — not checkpointed, could send events twice on retry
async ({ event }) => {
await inngest.send({ name: "my/event", data: {} }); // not a step!
};
// ✅ Safe — checkpointed, won't duplicate on retry
async ({ event, step }) => {
await step.sendEvent("trigger-downstream", { name: "my/event", data: {} });
};
Part 4: Chained Fan-Out — Downstream Events from Mid-Workflow
Sometimes the fan-out shouldn't happen at the very start of a workflow. You need to do some initial work first, and then trigger downstream functions based on the results.
This is chained fan-out: a function emits a new event mid-execution, and that event triggers a whole new wave of functions.
// ── Phase 1: Order processing ──────────────────────────────────────────────
// This function does the core work, then emits an event when it's done
export const processOrder = inngest.createFunction(
{ id: "process-order" },
{ event: "order/placed" },
async ({ event, step }) => {
// Core order processing — must complete before any notifications
const charge = await step.run("charge-customer", async () => {
return await paymentService.charge(event.data);
});
await step.run("update-inventory", async () => {
await inventoryService.decrement(event.data.items);
});
const fulfillment = await step.run("create-fulfillment", async () => {
return await warehouseService.createPickOrder(event.data);
});
await step.run("update-order-record", async () => {
await db.orders.update(event.data.orderId, {
status: "processing",
chargeId: charge.chargeId,
fulfillmentId: fulfillment.fulfillmentId,
});
});
// ✨ Now emit a downstream event — this triggers a whole new fan-out ✨
// All downstream notification functions fire from here, in parallel
await step.sendEvent("trigger-order-notifications", {
name: "order/confirmed",
data: {
orderId: event.data.orderId,
customerId: event.data.customerId,
customerEmail: event.data.customerEmail,
items: event.data.items,
chargeId: charge.chargeId,
estimatedDelivery: fulfillment.estimatedDelivery,
totalCents: event.data.totalCents,
},
});
return { orderId: event.data.orderId, status: "processing" };
},
);
// ── Phase 2: All notification functions listen to "order/confirmed" ────────
// They all run in parallel, independent of each other
export const sendOrderConfirmationEmail = inngest.createFunction(
{ id: "send-order-confirmation-email" },
{ event: "order/confirmed" },
async ({ event, step }) => {
await step.run("send-email", async () => {
await emailService.sendOrderConfirmation({
to: event.data.customerEmail,
orderId: event.data.orderId,
items: event.data.items,
estimatedDelivery: event.data.estimatedDelivery,
});
});
},
);
export const sendOrderSms = inngest.createFunction(
{ id: "send-order-sms" },
{ event: "order/confirmed" },
async ({ event, step }) => {
await step.run("lookup-phone", async () => {
return await db.customers.getPhone(event.data.customerId);
});
// ... send SMS
},
);
export const updateLoyaltyPoints = inngest.createFunction(
{ id: "update-loyalty-points" },
{ event: "order/confirmed" },
async ({ event, step }) => {
await step.run("add-points", async () => {
const pointsEarned = Math.floor(event.data.totalCents / 100);
await loyaltyService.addPoints(event.data.customerId, pointsEarned);
});
},
);
export const notifyWarehouseSystem = inngest.createFunction(
{ id: "notify-warehouse-system" },
{ event: "order/confirmed" },
async ({ event, step }) => {
await step.run("push-to-wms", async () => {
await warehouseManagementSystem.push({
orderId: event.data.orderId,
items: event.data.items,
priority: event.data.totalCents > 10000 ? "high" : "normal",
});
});
},
);
This architecture has an important property: the core order processing function is completely isolated from the notification concerns. If you remove SMS notifications, you delete sendOrderSms and nothing else changes. If you add a "notify the customer's account manager" function six months from now, you add a new consumer of order/confirmed without touching any existing code.
The chain is:
order/placed
└─► processOrder
└─► (emits) order/confirmed
├─► sendOrderConfirmationEmail
├─► sendOrderSms
├─► updateLoyaltyPoints
└─► notifyWarehouseSystem
Part 5: Fan-Out vs. Parallel Steps — Choosing the Right Tool
Both fan-out and parallel steps (Promise.all) run things simultaneously. Knowing when to use which is a judgment call — here's the framework.
Use parallel steps when:
- The parallel tasks are tightly coupled — they all belong to the same workflow and you need all their results to continue
- You want the entire unit to succeed or fail together — a failure in any parallel step fails the parent function
- The tasks are logically part of the same operation — sending an email AND updating a CRM record as part of a single "send welcome" workflow
- There are a small number of parallel items (up to ~20)
// Parallel steps: tightly coupled tasks in the same workflow
const [emailResult, crmResult] = await Promise.all([
step.run("send-welcome-email", () => emailService.send(email)),
step.run("add-to-crm", () => crm.addContact(user)),
]);
// Both results needed to continue
await step.run("log-onboarding", () =>
db.log({ emailId: emailResult.id, crmId: crmResult.id }),
);
Use fan-out when:
- The tasks are logically independent — a failure in one should not affect others
- You want to add new consumers later without modifying existing code
- The tasks are spread across different parts of your codebase (or even different apps)
- There are many items (dozens, hundreds, or thousands)
- Each task might have different retry strategies or concurrency limits
- You want separate observability — each task's run history visible individually
// Fan-out: independent tasks, separate concerns
// (just multiple functions listening to the same event)
// No shared code, no shared failure domain
Side-by-side comparison
| Parallel Steps | Fan-Out | |
|---|---|---|
| Failure isolation | No — one failure fails all | Yes — independent |
| Adding new work | Must modify the function | Add a new function |
| Result sharing | Yes — Promise.all returns all results | No — functions are independent |
| Observability | All steps in one run trace | Each function has its own run trace |
| Scale | Dozens of steps | Thousands of items |
| Retry strategy | Shared across all parallel steps | Independent per function |
| Best for | Tightly coupled operations | Decoupled side effects |
There's no wrong answer — sometimes you want tightly coupled parallel work, and sometimes you want independent fan-out. The key is recognising which you're dealing with.
Part 6: Practical Fan-Out Patterns
Pattern 1: The signup pipeline
The canonical fan-out example. Fire once on signup, trigger all onboarding side effects independently.
// One event, N independent onboarding functions
await inngest.send({
name: "user/account.created",
data: { userId, email, name, plan },
});
// Each of these listens to "user/account.created":
// - sendWelcomeEmail
// - createBillingTrial
// - addToCRM
// - notifySlack
// - addToMailingList
// - scheduleOnboardingSequence
Pattern 2: The batch processor
A cron or trigger function loads a list, fans out one event per item, a worker handles each item.
// Orchestrator (cron or triggered)
const items = await step.run("load-items", () => db.getItemsToProcess());
await step.sendEvent(
"fan-out",
items.map((item) => ({
name: "item/process.requested",
data: { itemId: item.id },
})),
);
// Worker (one execution per item)
inngest.createFunction(
{ id: "process-item", concurrency: { limit: 20 } },
{ event: "item/process.requested" },
async ({ event, step }) => {
/* process one item */
},
);
Pattern 3: The completion cascade
A function does core work, then emits an event that triggers N downstream reactions.
// Core function
await step.sendEvent("trigger-downstream", {
name: "order/confirmed",
data: { orderId, customerId, ... },
});
// Multiple functions listen to "order/confirmed":
// - sendConfirmationEmail
// - updateLoyaltyPoints
// - notifyWarehouseSystem
// - syncToERP
Pattern 4: Multi-app fan-out
Fan-out works across different codebases and even different programming languages. If you have a Python service that handles analytics and a TypeScript service that handles notifications, both can listen to the same Inngest event — because events are routed by name, not by code location.
TypeScript app: Python app:
sendWelcomeEmail ←──── user/account.created ────► record_signup_analytics
createBillingTrial ←──── (same event) ────► update_ml_features
This is one of the most powerful aspects of Inngest's event-driven model — it's a genuine integration layer between services, not just an internal workflow tool.
Part 7: Concurrency Controls for Large Fan-Out
When you fan out to thousands of functions simultaneously, you can overwhelm downstream services. If you send 50,000 emails at once, your email provider might rate-limit you. If you make 50,000 database writes simultaneously, you might hit connection pool limits.
The solution is the concurrency option on your worker function:
export const sendWeeklyDigest = inngest.createFunction(
{
id: "send-weekly-digest",
concurrency: {
limit: 50, // at most 50 runs of this function execute simultaneously
},
},
{ event: "digest/weekly.send" },
async ({ event, step }) => {
// ... send one digest email
},
);
With limit: 50, even if 50,000 events are queued, only 50 runs of this function execute at any given moment. As each one completes, another one starts. This gives you controlled, steady throughput instead of a thundering burst.
You can also scope concurrency to a specific key — for example, limiting to one concurrent run per user:
concurrency: {
limit: 1,
key: "event.data.userId", // at most 1 run per userId at a time
}
This prevents race conditions where two runs for the same user execute simultaneously and conflict with each other in the database.
Flow control — concurrency, throttling, rate limiting, and debouncing — is covered in depth in the module's production section. For now, the key takeaway is: always set concurrency limits on fan-out workers that hit external services.
Common Misconceptions
❌ Misconception: Fan-out is only useful for large-scale batch jobs
Reality: Fan-out is valuable even for a single event triggering just two or three functions. The value isn't in the scale — it's in the independence. Two functions that can fail separately, be deployed separately, and be tested separately are architecturally better than two blocks of code in a single function, regardless of how many items are involved.
❌ Misconception: Using inngest.send() inside a function is the same as step.sendEvent()
Reality: inngest.send() inside a function body is not checkpointed. If the function fails after the send and retries, inngest.send() fires again — potentially spawning duplicate downstream runs. step.sendEvent() is memoised: once the step completes, the events are recorded and won't fire again on retry. Always use step.sendEvent() when sending events from within a function.
❌ Misconception: All fan-out consumers see the same event simultaneously
Reality: Inngest dispatches all matching functions as fast as possible, but there's no guarantee of exact simultaneity. The first few functions may start before the last few are dispatched. For most use cases this doesn't matter — the functions are still effectively parallel. If strict ordering or exact coordination is required, consider step.waitForEvent() patterns instead.
❌ Misconception: You can only fan out from inngest.send() in an API route
Reality: Fan-out can originate from anywhere: an API route, a cron function, a step.sendEvent() call inside another function, or even an external webhook. Wherever an event is sent, all registered consumers for that event name will run.
Troubleshooting Common Issues
Problem: Only one function is running, not all of them
Symptoms: You fire user/account.created and see only one function run in the Dev Server, not the three or four you defined.
Diagnostic steps:
# 1. Check that all functions are registered in your serve handler
# Look at GET /api/inngest — it should list all your functions
curl http://localhost:3000/api/inngest
# 2. Check the Dev Server Apps page
# At http://localhost:8288/apps — does it show all your functions?
Common cause: Functions that aren't added to the functions array in serve() simply don't exist from Inngest's perspective. Check every consumer is imported and listed.
Problem: Fan-out worker is overwhelming a downstream service
Symptoms: After sending thousands of events, your email provider or API starts returning 429 rate-limit errors.
Solution: Add a concurrency limit to the worker function:
inngest.createFunction(
{
id: "my-worker",
concurrency: { limit: 20 }, // tune this number to your service's limits
},
{ event: "my/event" },
async ({ event, step }) => {
/* ... */
},
);
Start conservatively (10–20) and increase as you verify the downstream service can handle the load.
Problem: Duplicate events are firing for each item
Symptoms: You see twice as many function runs as expected in the Dev Server.
Most likely cause: You used inngest.send() inside a function instead of step.sendEvent(). When the function retried, inngest.send() fired the events again. Replace with step.sendEvent() which is idempotent on retry.
Check Your Understanding
Quick Quiz
1. You have four functions all listening to order/placed. You add a fifth without modifying any existing function. When order/placed fires, how many functions run?
Show Answer
All five. Inngest dispatches the event to every registered function that has it as a trigger. Adding a new consumer requires no changes to existing functions or to the code that fires the event — you just register the new function in your serve handler. This is one of fan-out's core architectural benefits.
2. What's the difference between this and the parallel steps pattern from Article 5?
// Option A: Parallel steps
const [emailRes, crmRes] = await Promise.all([
step.run("send-email", () => sendEmail(user)),
step.run("add-to-crm", () => addToCRM(user)),
]);
// Option B: Fan-out (two separate functions, same event trigger)
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/account.created" },
async ({ event, step }) => {
/* send email */
},
);
export const addUserToCRM = inngest.createFunction(
{ id: "add-user-to-crm" },
{ event: "user/account.created" },
async ({ event, step }) => {
/* add to CRM */
},
);
Show Answer
In Option A (parallel steps), both tasks are part of the same function run. If either fails after exhausting retries, the function itself fails. They share a run trace, a retry budget, and a deployment unit. You need to modify the existing function to add or remove tasks.
In Option B (fan-out), the two functions are completely independent. The email function failing has no effect on the CRM function. Each has its own run trace and retry budget. You can modify, deploy, or remove either function without touching the other. This independence is the key distinction.
Rule of thumb: If you need the results of both operations to continue, use parallel steps. If the operations are independent side effects, use fan-out.
3. You're writing a function that processes 10,000 users and needs to send each one a notification. Should you loop through them with step.run() inside the loop, or use step.sendEvent() to fan out?
Show Answer
Use step.sendEvent() to fan out. Here's why:
A loop with 10,000 step.run() calls creates 10,000 steps in a single function run. Each step re-executes the entire function from the top (the re-execution model from Article 5), making the function progressively slower with each step. It also means all 10,000 items share one function's state budget (32 MB total). And debugging means wading through 10,000 steps in a single run trace.
With step.sendEvent(), you send 10,000 events and each creates an independent function run. Each run is small, fast, and has its own run trace. If user #8,423 fails, only that run retries — the other 9,999 are unaffected. You can also set a concurrency limit on the worker to control throughput.
Fan-out is the right pattern whenever you're processing many items independently.
Hands-On Challenge
Refactor the monolithic handleUserSignup function from Part 1 into a fan-out architecture with four independent consumers.
Starting state: One function doing send-welcome-email, create-billing-trial, add-to-crm, notify-slack sequentially.
Target state: Four separate functions, all listening to user/account.created, running in parallel.
As a bonus: add a fifth consumer that logs the signup to an audit trail table — without modifying any of the four original consumers.
See the Solution Structure
// src/inngest/functions/signup/send-welcome-email.ts
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email", retries: 3 },
{ event: "user/account.created" },
async ({ event, step }) => {
await step.run("send", async () => {
await emailService.sendWelcome(event.data.email, event.data.name);
});
},
);
// src/inngest/functions/signup/create-billing-trial.ts
export const createBillingTrial = inngest.createFunction(
{ id: "create-billing-trial", retries: 5 }, // higher retries for billing
{ event: "user/account.created" },
async ({ event, step }) => {
const customer = await step.run("create-customer", async () => {
return await stripe.customers.create({ email: event.data.email });
});
await step.run("start-trial", async () => {
await stripe.subscriptions.create({
customer: customer.id,
trial_period_days: 14,
});
});
},
);
// src/inngest/functions/signup/add-to-crm.ts
export const addToCRM = inngest.createFunction(
{ id: "add-to-crm", retries: 3 },
{ event: "user/account.created" },
async ({ event, step }) => {
await step.run("create-contact", async () => {
await hubspot.contacts.create({
email: event.data.email,
name: event.data.name,
});
});
},
);
// src/inngest/functions/signup/notify-slack.ts
export const notifySlack = inngest.createFunction(
{ id: "notify-team-signup", retries: 1 }, // low retries for non-critical notification
{ event: "user/account.created" },
async ({ event, step }) => {
await step.run("post", async () => {
await slack.postMessage("#signups", `New signup: ${event.data.email}`);
});
},
);
// Bonus: fifth consumer added without touching any of the above
// src/inngest/functions/signup/log-audit-trail.ts
export const logAuditTrail = inngest.createFunction(
{ id: "log-signup-audit", retries: 2 },
{ event: "user/account.created" },
async ({ event, step }) => {
await step.run("log", async () => {
await db.auditLog.insert({
action: "user_signup",
userId: event.data.userId,
email: event.data.email,
timestamp: new Date().toISOString(),
});
});
},
);
Key observations: each function has its own retry strategy. The billing function gets 5 retries (it's mission-critical). The Slack notification gets 1 (it's best-effort). The audit log gets 2. This granular control is impossible in a monolithic function.
Summary: Key Takeaways
- Fan-out = one event, many functions. Multiple functions can register the same event as their trigger. Inngest dispatches all of them simultaneously when the event fires.
- Three variants: static (fixed set of consumers), dynamic (send one event per item to spawn many parallel runs), chained (emit a downstream event mid-workflow).
- Fan-out vs parallel steps: Parallel steps are for tightly coupled operations that share a result. Fan-out is for independent side effects with separate failure domains.
step.sendEvent()notinngest.send()when sending events inside a function.step.sendEvent()is checkpointed — it won't duplicate on retry.inngest.send()is not.- Batch size limit:
step.sendEvent()accepts arrays up to 512 KB total. Chunk large arrays across multiple send steps for very large fan-outs. - Add consumers without modifying existing code. This is the architectural superpower of fan-out — new behaviour is additive, not modifying.
- Set concurrency limits on workers that call external services to prevent overwhelming them when thousands of events arrive simultaneously.
What's Next?
You've now mastered the fan-out pattern — firing once and triggering independent parallel work across your system.
In Article 8: Step Coordination — Waiting for External Events (coming soon), we explore one of Inngest's most distinctive capabilities: pausing a function mid-execution and resuming it when a specific event arrives. This is how you build workflows that span hours, days, or even months — waiting for a user to confirm an email, a manager to approve a request, or an external system to complete its work.
Version Information
Tested with:
inngest:^4.1.x- Node.js: v18.x, v20.x, v22.x
- TypeScript: 5.x
Batch size limits:
step.sendEvent(): maximum 512 KB total payload per batch- Individual event: maximum 512 KB
Further reading:
- Fan-out (one-to-many) — Inngest Documentation — official fan-out guide with examples
- Sending Events from Functions — Inngest Documentation —
step.sendEvent()reference and patterns - Batching Jobs via Fan-Out — Inngest Patterns — cron-triggered batch processing pattern
- Concurrency — Inngest Documentation — controlling parallel execution for high-volume fan-out