Multi-Transport
Transports

Multi-Transport

Combine multiple transports with failover, load balancing, and redundancy

multiTransport() composes multiple transports behind a single Transport interface. Pick a strategy that matches how you want delivery to behave.

sendSMTPResendCloudflare?

Strategies split into two families:

  • Sequential — tries one transport at a time, advancing on failure. Honors maxAttemptsPerTransport and backoff.
  • Parallel — uses multi-transport execution instead of sequential failover. race and parallel start all transports immediately; mirrored awaits the primary first, then fires mirrors in the background. Ignores retry options.
StrategyFamilyBehavior
failoverSequentialTry in order, stop on first success
round-robinSequentialRotate start index across calls
randomSequentialRandom start index per call
raceParallelAll fire, first success wins
parallelParallelAll fire, all must succeed
mirroredParallelPrimary awaited, mirrors fire-and-forget

Sequential Strategies

Sequential strategies try one transport at a time. They differ only in where each send() starts:

StrategyFirst transport triedWhat happens after a failureBest fit
failoverAlways transports[0]Walk forward through the remaining transportsClear primary provider with explicit backups
round-robinNext index in the rotationContinue forward from that starting point for the current sendEvenly distribute load across equivalent providers
randomRandom index per sendContinue forward from that starting point for the current sendSpread load across many app instances without shared counters

Failover

Every send() starts at transports[0] and walks forward on failure. Use when you have a clear primary provider and one or more backups.

import { multiTransport } from '@betternotify/core/transports';

const transport = multiTransport({
  strategy: 'failover',
  transports: [
    { transport: resendTransport },
    { transport: smtpTransport },
  ],
});

Round-Robin

An in-process counter advances the start index on each send(), distributing load evenly across equivalent providers. On failure within a single send, it still walks forward through the remaining transports.

const transport = multiTransport({
  strategy: 'round-robin',
  transports: [
    { transport: smtpAccountA },
    { transport: smtpAccountB },
    { transport: smtpAccountC },
  ],
});

Random

Each send() picks a uniformly random start index, then walks forward on failure. Spreads load without per-process counter coordination — useful across multiple server instances.

const transport = multiTransport({
  strategy: 'random',
  transports: [
    { transport: providerA },
    { transport: providerB },
  ],
});

Parallel Strategies

Parallel strategies fan work out instead of walking the list. The important differences are when each branch starts, what the caller waits for, and which failures propagate:

StrategyWhen transports startWhen send() resolvesWhat causes send() to failBest fit
raceAll transports start immediatelyFirst successful transport winsAll transports failLowest-latency delivery across interchangeable providers
parallelAll transports start immediatelyAfter every transport succeedsAny transport failsRedundant delivery where every copy must land
mirroredtransports[0] starts first; mirrors start only after it succeedsAs soon as the primary succeedsThe primary failsPrimary delivery plus non-blocking audit or observability mirrors

Race

Dispatches to all transports in parallel via Promise.any(). Returns the first successful result; throws when all fail. Use for latency-sensitive sends where any provider can deliver.

const transport = multiTransport({
  strategy: 'race',
  transports: [
    { transport: regionUs },
    { transport: regionEu },
    { transport: regionApac },
  ],
});

Parallel

Dispatches to all transports in parallel via Promise.allSettled(). All must succeed — throws on any failure. Returns the first transport's data as the canonical result. Use for verified-redundancy delivery where every copy must land (e.g. primary + audit copy).

const transport = multiTransport({
  strategy: 'parallel',
  transports: [
    { transport: primaryProvider },
    { transport: auditCopy },
  ],
});

Mirrored

Awaits transports[0] (the primary) and returns its result. Remaining transports fire-and-forget in the background — failures log at warn level but never propagate. Use when secondary providers are observability mirrors whose failure should not affect the user-visible outcome.

const transport = multiTransport({
  strategy: 'mirrored',
  transports: [
    { transport: primaryProvider },
    { transport: analyticsProvider },
    { transport: complianceArchive },
  ],
});

Options

Prop

Type

Retries and Backoff

Sequential strategies support per-transport retries with exponential backoff. Parallel strategies ignore these options.

const transport = multiTransport({
  strategy: 'failover',
  transports: [
    { transport: primaryProvider },
    { transport: fallbackProvider },
  ],
  maxAttemptsPerTransport: 3,
  backoff: {
    initialMs: 100,
    factor: 2,
    maxMs: 5_000,
  },
  isRetriable: (err) => !(err instanceof RateLimitError),
});

Delay between retries on the same transport follows min(maxMs, initialMs × factor^(attempt-1)). The formula adds no jitter. Backoff resets when advancing to the next transport — advancing never sleeps.

Prop

Type

Verify and Close

In Better-Notify, verify() and close() are optional lifecycle hooks on the Transport interface. Like any other Transport, a multi-transport exposes them too:

  • verify() checks whether a transport is ready to send. Providers typically use it for startup checks such as validating credentials, testing connectivity, or confirming required configuration.
  • close() releases transport-owned resources when your app shuts down, such as sockets, clients, or background handles.

multiTransport() applies those hooks across the whole composite, so you can treat many transports as one transport during startup and shutdown.

  • verify() calls every inner verify() in parallel and returns { ok: true } when at least one transport verifies successfully. The full per-transport outcomes are returned in details.results, which makes it useful for “is any provider alive?” checks during boot or health probes.
  • close() calls every inner close() in parallel and waits for all cleanup work to finish. If one transport throws during shutdown, the error is logged and swallowed so the remaining transports still get a chance to close.

On this page