Overview

Fan-Out & Parallel Delivery

Send a message to multiple channels or recipients in parallel with independent failure handling.

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():

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([
    sendSlackAlert(incidentId, message),
    sendEmailAlert(incidentId, message),
    sendSmsAlert(incidentId, message),
    sendPagerDutyAlert(incidentId, message),
  ]);

  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.

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:

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(
    matched.map((channel) => deliverToChannel(channel, alertId, message))
  );

  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:

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(
    matched.map((sub) => deliverToSubscriber(sub.id, topic, payload))
  );

  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.

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);

  // 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;
  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