npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@khalidsaidi/fallback-chain-js

v0.2.0

Published

Tiny fallback chains for JS/TS — try providers until one succeeds.

Readme

fallback-chain-js

Tiny fallback chains for JS/TS — try providers until one succeeds.

CI npm types license bundle size

  • Tiny core, zero runtime deps
  • Works in Node, Bun, and Cloudflare Workers
  • Fallback on errors AND unacceptable results
  • AbortSignal + per-attempt timeouts
  • Optional stateful chains with per-candidate cooldown/health memory (createFallbackChain)
  • Streaming fallback with commit-on-first-chunk semantics (fallbackStream)
  • Great for HTTP, storage, and LLM/provider failover

Demo app: https://fallbacklab.vercel.app

Quickstart

import { fallback } from "@khalidsaidi/fallback-chain-js";

const result = await fallback([
  () => fetch("https://primary.example.com").then((r) => r.text()), // this host is down → falls back
  () => "hello from the backup provider"
]);

console.log(result); // "hello from the backup provider"

Why not X?

| Library | Difference | |---------|-----------| | Promise.any | Runs all promises immediately; this lib runs candidates lazily | | p-retry | Retries the same operation; this lib tries different providers | | cockatiel | Full resilience suite (circuit breakers, bulkheads); this lib is a focused primitive | | async-retry | Same-operation retry with backoff; no multi-provider support | | ai-fallback / Vercel AI Gateway | Provider failover built on/into the Vercel AI SDK. If you're all-in on the Vercel AI SDK, use those — they're deeply integrated. Use this when you're not: plain fetch, provider SDKs, non-LLM work, or when you want failover without adopting a framework |

This library is a primitive — single purpose, predictable, zero deps. Compose it with other tools as needed.

Install

npm i @khalidsaidi/fallback-chain-js

Usage

Basic: first success wins

const value = await fallback([
  () => primary(),
  () => secondary()
]);

Fallback on "bad results"

import { fallback, acceptOk } from "@khalidsaidi/fallback-chain-js";

const response = await fallback(
  [() => fetch(urlA), () => fetch(urlB)],
  { accept: acceptOk }
);

Timeouts + AbortSignal

const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);

const value = await fallback([
  ({ signal }) => fetch(urlA, { signal }).then((r) => r.json()),
  ({ signal }) => fetch(urlB, { signal }).then((r) => r.json())
], {
  signal: controller.signal,
  timeoutMs: 1_000
});

An invalid timeoutMs (negative, NaN, or not a number) throws a TypeError immediately — it never silently disables the timeout.

Abort-listener footgun to know about: an AbortSignal that is already aborted never fires its "abort" event, so the pattern signal.addEventListener("abort", handler, { once: true }) silently does nothing if the signal aborted before you attached the listener. Always check signal.aborted first, then attach with { once: true }. For the signal you pass into fallback()/fallbackStream(), the library already handles both cases for you.

Accept Helpers

Built-in validators for common patterns:

import {
  fallback,
  acceptOk,      // res.ok === true
  acceptStatus,  // res.status in [200, 201, ...]
  acceptTruthy,  // Boolean(value) === true
  acceptDefined  // value !== null && value !== undefined
} from "@khalidsaidi/fallback-chain-js";

// HTTP responses: fall back past the 500
const res = await fallback([
  () => ({ ok: false, status: 500 }), // stand-in for fetch(primaryUrl)
  () => ({ ok: true, status: 200 })   // stand-in for fetch(backupUrl)
], { accept: acceptOk });
console.log(res.status); // 200

// ...or accept only specific status codes
const created = await fallback([
  () => ({ ok: false, status: 500 }),
  () => ({ ok: true, status: 201 })
], { accept: acceptStatus(200, 201, 204) });
console.log(created.status); // 201

// General values: skip empty/undefined results
const text = await fallback([() => "", () => "real content"], { accept: acceptTruthy });
console.log(text); // "real content"

const cached = await fallback([() => undefined, () => 0], { accept: acceptDefined });
console.log(cached); // 0 (defined, even though falsy)

Real-World Examples

LLM Provider Failover

Different providers return different response shapes, so each candidate normalizes to one common type. That lets a single accept (and your caller) work with every provider:

interface LLMResult {
  provider: string;
  text: string;
}

const result = await fallback<LLMResult>([
  {
    name: "openai",
    run: async () => {
      const res = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: prompt }]
      });
      return { provider: "openai", text: res.choices[0]?.message?.content ?? "" };
    }
  },
  {
    name: "anthropic",
    run: async () => {
      const res = await anthropic.messages.create({
        model: "claude-sonnet-4-5",
        max_tokens: 1024,
        messages: [{ role: "user", content: prompt }]
      });
      const block = res.content[0];
      return { provider: "anthropic", text: block?.type === "text" ? block.text : "" };
    }
  }
], {
  accept: (r) => r.text.length > 0, // one accept, one shape
  timeoutMs: 30_000,
  onAttempt: ({ name, outcome }) => console.log(`${name}: ${outcome}`)
});

console.log(`${result.provider} said: ${result.text}`);

Multi-Region Storage

import { fallback, acceptDefined } from "@khalidsaidi/fallback-chain-js";

// Stand-ins — swap for your real clients (AWS SDK S3, Cloudflare R2 bindings, ...)
const s3UsEast = { getObject: async (key: string): Promise<string | undefined> => { throw new Error("region down"); } };
const s3EuWest = { getObject: async (key: string): Promise<string | undefined> => `object:${key}` };
const r2 = { get: async (key: string): Promise<string | undefined> => `object:${key}` };
const key = "report.pdf";

const data = await fallback([
  () => s3UsEast.getObject(key),
  () => s3EuWest.getObject(key),
  () => r2.get(key)
], { accept: acceptDefined });

console.log(data); // "object:report.pdf" — served by eu-west after us-east failed

Cache-Through Pattern

import { fallback, acceptDefined } from "@khalidsaidi/fallback-chain-js";

// Stand-ins — swap for your real clients (ioredis, pg, an internal service API, ...)
const redis = { get: async (k: string) => null };                       // cache miss
const postgres = { query: async (sql: string, params: unknown[]) => ({ id: params[0], name: "Ada" }) };
const userServiceApi = { getUser: async (id: string) => ({ id, name: "Ada" }) };
const id = "42";

const user = await fallback([
  () => redis.get(`user:${id}`),
  () => postgres.query("SELECT * FROM users WHERE id = $1", [id]),
  () => userServiceApi.getUser(id)
], { accept: acceptDefined });

console.log(user); // { id: "42", name: "Ada" } — from Postgres after the cache miss

Stateful Chains: Cooldown + Health (createFallbackChain)

fallback() is stateless — every call starts at candidate #1. When a provider is having a bad hour, you don't want to pay its timeout on every single call. createFallbackChain remembers recent failures per candidate:

  • A candidate that fails goes on cooldown (default 30s, configurable).
  • Cooling-down candidates are deprioritized to the end of the chain — not skipped outright, so the chain can never fail purely because everything is cooling down.
  • A success resets that candidate's failure memory.
  • failureThreshold (default 1) sets how many consecutive failures trigger the cooldown.

It's an optional, tree-shakeable wrapper — the plain fallback() is untouched.

import { createFallbackChain } from "@khalidsaidi/fallback-chain-js";

const chain = createFallbackChain([
  { name: "primary", run: () => callPrimary() },
  { name: "backup", run: () => callBackup() }
], {
  cooldownMs: 30_000,     // deprioritize a failing candidate for 30s
  failureThreshold: 1,    // ...after a single failure
  timeoutMs: 5_000        // all fallback() options work here too
});

// Call it per request. While "primary" is cooling down, "backup" is tried first.
const a = await chain.run();
const b = await chain.run({ signal: requestSignal }); // per-call overrides merge over base options

chain.health();
// [
//   { index: 0, name: "primary", consecutiveFailures: 1, coolingDown: true, cooldownUntil: 1735000030000, lastError: Error },
//   { index: 1, name: "backup", consecutiveFailures: 0, coolingDown: false, cooldownUntil: 0 }
// ]

chain.reset(); // clear all failure/cooldown memory

Semantics worth knowing:

  • Failures that count against health: rejections, timeouts, and accept vetoes. Caller-initiated aborts do not count — they say nothing about the candidate.
  • Concurrent chain.run() calls are safe: each call snapshots an ordering up front, and every settled attempt applies one synchronous state update.
  • After a cooldown expires the candidate is tried in its original position again; if it fails once more it immediately re-enters cooldown.
  • Post-cooldown revival is not serialized: each run() snapshots the ordering up front, so N concurrent calls arriving just after a cooldown expires may each probe the revived candidate before the first probe settles. If a single canary probe matters (an expensive or fragile provider), serialize it yourself — e.g. wrap chain.run() in a small in-flight dedupe/mutex while a candidate is coming off cooldown.

Streaming Fallback (fallbackStream)

Falling back on a stream (an LLM token stream, an SSE feed) has a hard constraint that request/response fallback doesn't: once the consumer has seen chunks from provider A, you cannot splice provider B onto them — B would restart from the beginning and the consumer would render a garbled mixture. Mid-stream fallback is unsolvable UX, so fallbackStream doesn't attempt it. The semantics are deliberately crisp:

  • Before the first chunk reaches the consumer, anything goes wrong — error, timeout, acceptFirstChunk veto — and we fall back to the next candidate. Nothing has been shown, so falling back is invisible.
  • From the first chunk on, we are committed: errors propagate as-is.
  • Abandoned candidates are cleaned up: their iterator's return() is called and late-arriving chunks are dropped, never yielded to the consumer.
  • Consumer cleanup propagates: break/return()/throw() on the outer stream reaches the inner iterator (your provider's finally runs).
  • A stream that completes cleanly with zero chunks completes the output stream — clean completion is not an error.
import { fallbackStream } from "@khalidsaidi/fallback-chain-js";

const stream = fallbackStream([
  { name: "openai", run: ({ signal }) => openaiTokenStream(prompt, signal) },
  { name: "anthropic", run: ({ signal }) => anthropicTokenStream(prompt, signal) }
], {
  timeoutMs: 3_000,                       // time budget to the FIRST chunk, per attempt
  acceptFirstChunk: (chunk) => chunk.length > 0, // veto before committing
  retryable: (err) => !isAuthError(err),  // don't rotate providers on bad credentials
  onAttempt: ({ name, outcome }) => console.log(`${name}: ${outcome}`)
});

for await (const token of stream) {
  process.stdout.write(token);
}

Candidates can return any AsyncIterable, AsyncIterator, or sync Iterable — async generators, ReadableStreams (they're async-iterable in Node/Workers/Bun), or arrays.

timeoutMs measures time to first chunk only. Once committed, a slow chunk mid-stream is between the consumer and the provider (use ctx.signal or an outer AbortSignal for whole-stream deadlines).

Recipes

This library is a primitive. Here's how to compose it for advanced patterns:

Retryable LLM Errors

Fall back on errors that a different provider might fix (429 rate limits, 5xx, timeouts, overloaded); throw fast on errors that every provider will reject the same way (bad request, bad API key, content policy):

import { fallback, TimeoutError } from "@khalidsaidi/fallback-chain-js";

function retryableLLM(err: unknown): boolean {
  if (err instanceof TimeoutError) return true;         // provider too slow → try next
  const status = (err as any)?.status ?? (err as any)?.response?.status;
  if (status === 429) return true;                      // rate limited → try next
  if (typeof status === "number" && status >= 500) return true; // provider down → try next
  if ((err as any)?.error?.type === "overloaded_error") return true; // Anthropic 529-style
  return false; // 400/401/403/content-policy: every provider agrees — throw fast
}

const result = await fallback([
  { name: "openai", run: () => askOpenAI(prompt) },
  { name: "anthropic", run: () => askAnthropic(prompt) }
], {
  retryable: retryableLLM,
  timeoutMs: 30_000
});

Retry Within a Provider, Fall Back Across Providers

p-retry retries the same operation; this lib rotates different providers. They compose cleanly — give each provider a few attempts with backoff before rotating:

import pRetry from "p-retry";
import { fallback } from "@khalidsaidi/fallback-chain-js";

const withRetries = (fn: () => Promise<string>) =>
  pRetry(fn, { retries: 2, minTimeout: 250 }); // 3 total attempts, exponential backoff

const result = await fallback([
  { name: "openai", run: () => withRetries(() => askOpenAI(prompt)) },
  { name: "anthropic", run: () => withRetries(() => askAnthropic(prompt)) }
]);
// Worst case: openai tried 3×, then anthropic tried 3×.

Hedged Requests

Start a backup request if the primary is slow (Google's "Tail at Scale" pattern):

async function hedge<T>(
  primary: () => Promise<T>,
  backup: () => Promise<T>,
  hedgeAfterMs: number
): Promise<T> {
  const controller = new AbortController();
  let backupStarted = false;

  const withBackup = new Promise<T>((resolve) => {
    setTimeout(() => {
      if (!controller.signal.aborted) {
        backupStarted = true;
        backup().then(resolve);
      }
    }, hedgeAfterMs);
  });

  const result = await Promise.race([
    primary().then((v) => { controller.abort(); return v; }),
    withBackup
  ]);

  return result;
}

// Usage
const data = await hedge(
  () => fetchPrimary(),
  () => fetchBackup(),
  100 // start backup if primary takes >100ms
);

Parallel Race with Accept

Run all candidates in parallel, first acceptable result wins:

async function race<T>(
  candidates: Array<() => Promise<T>>,
  accept: (v: T) => boolean = () => true
): Promise<T> {
  const controller = new AbortController();

  return Promise.any(
    candidates.map(async (fn) => {
      const value = await fn();
      if (!accept(value)) throw new Error("unacceptable");
      controller.abort();
      return value;
    })
  );
}

Get Winner Metadata

Track which candidate succeeded using the existing onAttempt hook. info.attempt is 0-based: 0 means the first candidate won, 1 the second, and so on.

import { fallback } from "@khalidsaidi/fallback-chain-js";

let winner: { name?: string; attempt: number; durationMs: number } | undefined;

const value = await fallback([
  { name: "primary", run: () => fetchPrimary() },
  { name: "backup", run: () => fetchBackup() }
], {
  onAttempt: (info) => {
    if (info.outcome === "success") {
      // info.attempt: 0 = primary, 1 = backup
      winner = { name: info.name, attempt: info.attempt, durationMs: info.durationMs };
    }
  }
});

console.log(`Winner: ${winner?.name} (attempt #${winner?.attempt})`);

API

fallback<T>(
  candidates: readonly Candidate<T>[],
  options?: FallbackOptions<T>
): Promise<T>

Candidates:

  • () => T | Promise<T>
  • { name?: string, run: (ctx) => T | Promise<T> }

Options:

  • signal?: AbortSignal
  • timeoutMs?: number | (ctx) => number | undefined — invalid values (negative, NaN, non-number) throw a TypeError
  • accept?: (value, { attempt }) => boolean
  • retryable?: (error, { attempt }) => boolean
  • onAttempt?: ({ attempt, name, outcome, durationMs, value?, error? }) => void

attempt is 0-based everywhere it appears — in accept, retryable, onAttempt, and the ctx passed to candidates and to the timeoutMs function. The first candidate runs as attempt: 0.

Errors:

  • TimeoutError — candidate exceeded timeoutMs
  • FallbackError — all candidates failed. .errors is the per-attempt error array (in attempt order). .named is the same list as [{ name?, error }], carrying each candidate's name when it was given as { name, run } — and named candidates show up in the message too, e.g. All 2 fallback candidates failed (primary: Error: boom; backup: TimeoutError: Timed out after 1000ms)
createFallbackChain<T>(
  candidates: readonly Candidate<T>[],
  options?: FallbackChainOptions<T>
): FallbackChain<T>

Options: everything fallback() takes, plus:

  • cooldownMs?: number — deprioritization window after failures (default 30_000)
  • failureThreshold?: number — consecutive failures before cooldown (default 1)
  • now?: () => number — injectable clock for tests

Returns:

  • run(overrides?) — run the chain once; healthy candidates first, cooling ones last
  • health() — per-candidate { index, name?, consecutiveFailures, coolingDown, cooldownUntil, lastError? }
  • reset() — clear all failure/cooldown memory
fallbackStream<T>(
  candidates: readonly StreamCandidate<T>[],
  options?: FallbackStreamOptions<T>
): AsyncGenerator<T>

Candidates: (ctx) => AsyncIterable<T> | AsyncIterator<T> | Iterable<T> (or { name?, run })

Options:

  • signal?: AbortSignal
  • timeoutMs? — per-attempt budget to the first chunk
  • retryable?: (error, { attempt }) => boolean — applies to pre-first-chunk errors
  • acceptFirstChunk?: (chunk, { attempt }) => boolean — veto a candidate before committing
  • onAttempt? — same shape as fallback(); "success" means committed (or clean empty completion)

As with fallback(), attempt is 0-based in retryable, acceptFirstChunk, onAttempt, and the candidate ctx.

Semantics: fallback happens only before the first chunk reaches the consumer; after that, errors propagate (mid-stream fallback is unsolvable UX).

Runtime Support

Node 18+ / Bun / Cloudflare Workers (tested in CI)

Contributing

pnpm install
pnpm -C packages/fallback-chain-js build
pnpm -C packages/fallback-chain-js test

License

MIT