workflow-recipient-list: When Notification Fan-Out Becomes a Live, Durable Debugger

A Next.js reference implementation that shows how the Vercel Workflow SDK turns multi-channel delivery into a replayable, observable system instead of a pile of queues and retries.

8 min read • View on GitHub • More from vercel-labs

A notification dispatch desk where one incoming message splits into three delivery paths, while a glass panel behind it maps the same events onto code. The scene explains how the repo makes fan-out, retries, and branch decisions visible inside the app itself.
One event, many recipients, and a runtime that narrates itself back to the developer.
Key Takeaways

The first surprise is not that this app can send to Slack, email, and PagerDuty. It is that it can explain each branch while it runs. `workflow-recipient-list` treats durable execution like something you should be able to read, not just operate.

The notification system that explains itself

That changes the shape of the problem. A recipient list is usually where teams bolt together queues, retries, cron jobs, and a separate dashboard. This repo collapses that stack into one workflow and one UI, so the delivery logic and the debugging surface stay in the same mental model.

Workflow Development Kit brings durability, reliability, and observability to async JavaScript so you can build apps and AI agents that suspend, resume, and maintain state with ease.

Pauline P. Narvas, Vercel Community Member · Open source Workflow Development Kit is now in public beta

The workbench turns a workflow run into a timeline you can inspect, scrub, and map back to the exact code path that ran.

Why recipient lists are harder than they look

A recipient list sounds simple until you ship it. Channels have different SLAs, different failure modes, and different business rules. Some are optional, some are mandatory, and some should retry forever while others should fail fast.

ApproachDurabilityVisibilityFailure handlingDeveloper burden
Cron jobs plus queuesPossible, but assembled from partsSpread across logs, workers, and dashboardsUsually custom and inconsistentHigh
Manual orchestration and persistenceStrong if you build it correctlyDepends on your toolingExplicit, but hand-rolledVery high
Vercel Workflow SDKBuilt into the workflow modelLive events plus code-linked UIBranch-aware with retries and fatal exitsLower

That is why the pattern matters. Fan-out is where distributed systems stop being elegant and start becoming operational. The repo’s answer is to make each recipient branch durable on its own, instead of letting one failure collapse the whole delivery.

The workflow engine is the product

In `workflows/recipient-list.ts`, the architecture is the argument. The `"use workflow"` directive defines the durable boundary, while `"use step"` wraps each delivery so the runtime can checkpoint progress and resume after interruption.

"use workflow";

export async function recipientList(input: RecipientListInput) {
  const recipients = await getRecipients(input);

  const results = await Promise.allSettled(
    recipients.map((recipient) =>
      deliverToRecipient(recipient, input.message)
    )
  );

  return summarize(results);
}

async function deliverToRecipient(recipient: Recipient, message: string) {
  "use step";
  return sendChannel(recipient.channel, message);
}

`Promise.allSettled()` is the quiet hero here. It lets the workflow fan out in parallel without turning the whole run into an all-or-nothing bet. If Email fails, Slack can still succeed, and the final summary can tell you exactly what happened.

A close-up of a relay mechanism with two tracks. One track keeps moving through a normal delivery path, while the other hits a hard stop sign on a broken branch and a separate retry arm loops beside it. The image explains how permanent failures and transient retries are treated differently inside the workflow.
Permanent failure should stop the branch, not waste retries on a problem that cannot recover.

That is where `FatalError` matters. It tells the runtime that some failures are permanent, not temporary. That distinction is expensive to get wrong, because indiscriminate retries waste time, money, and operator trust.

The function suspends for 7 days without consuming any resources, then resumes exactly where it left off.

Jerrick Hakim, Software Developer · Jerrick Hakim - Professional Website

From runtime events to highlighted lines of code

The workbench is the sharpest part of the repo. The API route exposes a readable stream for a workflow run, the frontend reducer folds those events into local state, and the code viewer lights up the line that corresponds to the branch that just executed.

That turns debugging into a spatial task. You are not hunting through logs to infer what happened. You are looking at the same workflow through three synchronized views: trigger, timeline, and source.

What this replaces, and what it still cannot

ModelStrengthWeaknessBest fit
Cron plus queuesFamiliar and widely portableVisibility is fragmentedSimple background jobs
Manual workflow systemsMaximum controlLots of plumbing and glue codeTeams with custom platform needs
Vercel Workflow SDKFast path to durable, inspectable workflowsTighter platform bet and younger ecosystemProduct teams that want durability inside app code

So the tradeoff is clear. This repo is not pretending to be a universal workflow philosophy. It is a focused answer to a painful class of problems, and it is strongest when you want reliability and debug visibility without building a platform team around them.

The bigger bet behind the demo

The broader idea is bigger than notifications. It is that durable execution can feel like normal application code, with a UI that speaks the same language as the source. If that thesis holds, the most valuable thing here is not a sender, a queue, or even a retry policy. It is the fact that the system can explain itself while you are still reading the code.