Aviary
Concepts

Fallback chains

Deliver a notification down an ordered chain of channels — push first, escalate to SMS, then email — stopping at the first that reaches the recipient. Opt in per notification with fallback().

By default a notification fans out to every channel its via() resolves to, in parallel. A fallback chain does the opposite: it tries channels in order, escalating to the next only when the current one doesn't reach the recipient — and stops at the first that does. It models real-world escalation: try push; if it isn't delivered, SMS; if that fails, email.

Opt in with fallback()

A notification declares a chain by implementing fallback(). Return an ordered FallbackPolicy; return undefined (or omit the method) and delivery is unchanged — the normal parallel fan-out.

critical-alert.notification.ts
@Notification()
export class CriticalAlert implements FallbackAware {
  fallback(notifiable: Notifiable): FallbackPolicy {
    return {
      channels: ['push', 'sms', 'mail'], // preferred → last resort
      timeoutMs: 10 * 60_000,            // how long to wait for delivery confirmation per channel
    };
  }

  @Push() toPush() { /* … */ }
  @Sms() toSms() { /* … */ }
  @Mail() toMail() { /* … */ }
}

FallbackPolicy is { channels: string[]; timeoutMs?: number }. The result reports which channel won via deliveredVia.

How "delivered" is decided

The chain escalates when a channel doesn't reach the recipient. By default that's the channel's immediate result — a sent result counts as delivered, anything else escalates to the next channel.

For channels where "sent" doesn't mean "reached" (a push can be sent but never opened), bind a DeliveryConfirmation probe. The chain then waits up to timeoutMs for it to confirm before deciding to escalate:

interface DeliveryConfirmation {
  // Did `channel` reach `notifiable` within timeoutMs? Return false to escalate.
  confirm(input: {
    channel: string;
    notifiable: Notifiable;
    notification: Notification;
    result: ChannelResult;
    timeoutMs: number;
  }): Promise<boolean>;
}

Bind it under the NOTIFICATION_DELIVERY_CONFIRMATION token:

app.module.ts
NotificationsModule.forRoot({
  providers: [
    {
      provide: NOTIFICATION_DELIVERY_CONFIRMATION,
      useValue: {
        confirm: async ({ channel, notifiable, timeoutMs }) => {
          // e.g. poll your push provider's receipts, or a read flag, until timeoutMs
          return await wasOpened(channel, notifiable, timeoutMs);
        },
      },
    },
  ],
});

Fallback chains apply to synchronous delivery (the inline send/sendNow path), where the chain can wait and escalate. They also honor ad-hoc channel scoping — only() / except() narrow the channels the chain considers.

The primitives underneath

The fallback() hook is the declarative front door, but the escalation logic is a small set of pure, exported functions you can reach for directly — to build a bespoke chain, unit-test one, or drive an escalation from outside a notification.

readFallback(notification) duck-types the fallback hook off any notification instance — it just returns the object typed as FallbackAware, so you can inspect notification.fallback?.(notifiable) without a compile-time implements:

import { readFallback } from '@dudousxd/nestjs-notifications-core';

const policy = readFallback(notification).fallback?.(user); // FallbackPolicy | undefined

runFallbackChain(channels, deliver, isDelivered) is the engine: it attempts each channel in order via your deliver callback, asks isDelivered after each attempt, and stops at the first that reached the recipient — returning every attempt's result plus the winning channel. It's transport-agnostic; you supply how a channel is delivered and how "delivered" is judged:

import { runFallbackChain, deliveredFromResult } from '@dudousxd/nestjs-notifications-core';

const outcome = await runFallbackChain(
  ['push', 'sms', 'mail'],
  (channel) => runner.deliver(channel, user, notification),   // Promise<ChannelResult>
  (result) => deliveredFromResult(result),                    // sent? stop : escalate
);

outcome.deliveredVia; // 'push' | 'sms' | 'mail' | undefined (all failed)
outcome.results;      // ChannelResult[] — one per channel actually attempted, in order

deliveredFromResult(result) is the default decision the library uses when no DeliveryConfirmation probe is bound: a channel counts as delivered only when its immediate result is sent — anything else (failed, skipped, deferred, …) escalates. Pass your own predicate to runFallbackChain (e.g. one that awaits a delivery-receipt poll) to override it.

These are the exact pieces the core wires together for fallback(). You rarely need them directly — reach for them when you're composing escalation into your own orchestration, or writing a focused test around the ordering without booting the module.

Fallback vs. the other reliability tools

  • Fallback chainone notification, many channels, tried in order. "Reach them somehow."
  • Failoverone channel, many providers, tried in order. "Send the email even if provider A is down."
  • Dispatch guardsdon't over-send. Dedup and rate-limit before any channel runs.

They compose: a throttled, deduped notification can still escalate down a fallback chain.

On this page