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.allSettledoverPromise.all.allSettledlets 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
FatalErrorfor 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"-- marks the orchestrator function"use step"-- marks functions that run with full Node.js accessPromise.allSettled()-- fans out to all targets, isolating failuresFatalError-- prevents automatic retry for permanent failures