---
title: Fan-Out & Parallel Delivery
description: Send a message to multiple channels or recipients in parallel with independent failure handling.
type: guide
summary: Fan out an incident alert to Slack, email, SMS, and PagerDuty simultaneously using Promise.allSettled, so a failure in one channel does not block the others.
---

# Fan-Out & Parallel Delivery



Use fan-out when one event needs to trigger multiple independent actions in parallel. Each action runs as its own step, so failures are isolated -- a Slack outage doesn't prevent the email from sending.

## When to use this

* Incident alerting across multiple channels (Slack, email, SMS, PagerDuty)
* Notifying a list of recipients determined at runtime
* Any "broadcast" where each delivery is independent

## Pattern: Static fan-out

Define one step per channel and launch them all with `Promise.allSettled()`:

```typescript
declare function sendSlackAlert(incidentId: string, message: string): Promise<any>; // @setup
declare function sendEmailAlert(incidentId: string, message: string): Promise<any>; // @setup
declare function sendSmsAlert(incidentId: string, message: string): Promise<any>; // @setup
declare function sendPagerDutyAlert(incidentId: string, message: string): Promise<any>; // @setup

export async function incidentFanOut(incidentId: string, message: string) {
  "use workflow";

  const settled = await Promise.allSettled([ // [!code highlight]
    sendSlackAlert(incidentId, message),
    sendEmailAlert(incidentId, message),
    sendSmsAlert(incidentId, message),
    sendPagerDutyAlert(incidentId, message),
  ]); // [!code highlight]

  const ok = settled.filter((r) => r.status === "fulfilled").length;
  return { incidentId, delivered: ok, failed: settled.length - ok };
}
```

### Step functions

Each channel is a separate `"use step"` function. Steps have full Node.js access and retry automatically on transient failures.

```typescript
async function sendSlackAlert(incidentId: string, message: string) {
  "use step";
  await fetch("https://hooks.slack.com/services/T.../B.../xxx", {
    method: "POST",
    body: JSON.stringify({ text: `[${incidentId}] ${message}` }),
  });
  return { channel: "slack" };
}

async function sendEmailAlert(incidentId: string, message: string) {
  "use step";
  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
    body: JSON.stringify({
      to: [{ email: "oncall@example.com" }],
      subject: `Incident ${incidentId}`,
      content: [{ type: "text/plain", value: message }],
    }),
  });
  return { channel: "email" };
}

async function sendSmsAlert(incidentId: string, message: string) {
  "use step";
  // Call Twilio or similar SMS provider
  return { channel: "sms" };
}

async function sendPagerDutyAlert(incidentId: string, message: string) {
  "use step";
  // Call PagerDuty Events API
  return { channel: "pagerduty" };
}
```

## Pattern: Dynamic recipient list

When recipients are determined at runtime (e.g., severity-based routing), build the list dynamically:

```typescript
type Severity = "info" | "warning" | "critical";

const RULES = [
  { channel: "slack", match: () => true },
  { channel: "email", match: (s: Severity) => s === "warning" || s === "critical" },
  { channel: "pagerduty", match: (s: Severity) => s === "critical" },
];

export async function alertByRecipientList(
  alertId: string,
  message: string,
  severity: Severity
) {
  "use workflow";

  const matched = RULES.filter((r) => r.match(severity)).map((r) => r.channel);

  const settled = await Promise.allSettled( // [!code highlight]
    matched.map((channel) => deliverToChannel(channel, alertId, message))
  ); // [!code highlight]

  const delivered = settled.filter((r) => r.status === "fulfilled").length;
  return { alertId, severity, matched, delivered, failed: matched.length - delivered };
}

async function deliverToChannel(
  channel: string,
  alertId: string,
  message: string
): Promise<void> {
  "use step";
  // Route to the appropriate API based on channel name
  await fetch(`https://notifications.example.com/${channel}`, {
    method: "POST",
    body: JSON.stringify({ alertId, message }),
  });
}
```

## Pattern: Publish-subscribe

When subscribers are managed in a registry and filtered by topic:

```typescript
type Subscriber = { id: string; name: string; topics: string[] };

export async function publishEvent(topic: string, payload: string) {
  "use workflow";

  const subscribers = await loadSubscribers();
  const matched = subscribers.filter((sub) => sub.topics.includes(topic));

  await Promise.allSettled( // [!code highlight]
    matched.map((sub) => deliverToSubscriber(sub.id, topic, payload))
  ); // [!code highlight]

  return { topic, delivered: matched.length, total: subscribers.length };
}

async function loadSubscribers(): Promise<Subscriber[]> {
  "use step";
  // Load from database or configuration service
  return [
    { id: "sub-1", name: "Order Service", topics: ["orders", "inventory"] },
    { id: "sub-2", name: "Email Notifier", topics: ["orders", "shipping"] },
    { id: "sub-3", name: "Analytics", topics: ["orders", "inventory", "shipping"] },
  ];
}

async function deliverToSubscriber(
  subscriberId: string,
  topic: string,
  payload: string
): Promise<void> {
  "use step";
  await fetch(`https://subscribers.example.com/${subscriberId}/deliver`, {
    method: "POST",
    body: JSON.stringify({ topic, payload }),
  });
}
```

## Deferred await (background steps)

You don't have to await a step immediately. Start a step, do other work, and collect the result later. This is different from `Promise.all` -- you interleave sequential and parallel work instead of waiting for everything at once.

```typescript
declare function generateReport(data: Record<string, string>): Promise<any>; // @setup
declare function sendNotification(userId: string, message: string): Promise<void>; // @setup
declare function updateDashboard(userId: string): Promise<void>; // @setup

export async function onboardUser(userId: string, data: Record<string, string>) {
  "use workflow";

  // Start report generation in the background
  const reportPromise = generateReport(data); // [!code highlight]

  // Do other work while the report generates
  await sendNotification(userId, "Processing started");
  await updateDashboard(userId);

  // Now await the report when we actually need it
  const report = await reportPromise; // [!code highlight]
  return { userId, report };
}
```

The workflow runtime tracks the background step like any other. If the workflow replays, the already-completed step returns its cached result instantly.

## Tips

* **Use `Promise.allSettled` over `Promise.all`.** `allSettled` lets you know which channels failed without aborting the others.
* **Each delivery is an independent step.** Transient failures (e.g., Slack 503) trigger automatic retries without affecting other channels.
* **Use `FatalError` for permanent failures** (e.g., PagerDuty not configured) to stop retries on that channel while letting others continue.
* **Dynamic recipient lists** decouple routing from delivery -- adding a new channel is a configuration change, not a code change.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
* [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- fans out to all targets, isolating failures
* [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents automatic retry for permanent failures


## Sitemap
[Overview of all docs pages](/sitemap.md)
