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

better-race

v0.2.0

Published

A better Promise.race() for TypeScript: keyed results and optional cancellation.

Readme

better-race

A better Promise.race() for TypeScript: keyed results, precise narrowing, and optional cooperative cancellation.

CI npm version npm downloads coverage: 100% TypeScript ≥5.4 Node ≥22 tree-shaken bundle size license: MIT

ESM-only · zero runtime dependencies · tree-shakeable · AbortSignal-native

Promise.race() tells you the first value that settled. better-race also tells you which task produced it, while keeping that key and value connected in TypeScript.

import { race } from "better-race";

const winner = await race(
  {
    eu: ({ signal }) => fetch("https://eu.example.com/user/42", { signal }),
    us: ({ signal }) => fetch("https://us.example.com/user/42", { signal }),
  },
  { abortLosers: true },
);

if (winner.key === "eu") {
  winner.value; // the Response from the EU replica
}

Why?

Native Promise.race() returns a value union but drops its source:

const value = await Promise.race([readEuReplica(), readUsReplica()]);
// EuUser | UsUser

You can restore the source by manually wrapping every promise. better-race makes that wrapper the default, preserves the relation as a discriminated union, and can abort cooperative losers when that saves work.

Installation

npm install better-race

better-race is ESM-only, targets ES2022, and supports Node.js 22 or newer. It is framework-agnostic: it uses only promises and the standard AbortSignal API.

race(tasks, options?)

Pass an object whose string keys name tasks. A task can return a value, a promise, or any PromiseLike value. It receives a context containing an AbortSignal, which it may ignore.

import { race } from "better-race";

const winner = await race(
  {
    eu: ({ signal }) => euReplica.get("user-42", { signal }),
    us: ({ signal }) => usReplica.get("user-42", { signal }),
    apac: ({ signal }) => apacReplica.get("user-42", { signal }),
  },
  { abortLosers: true },
);

The result is inferred as:

{ key: "eu"; value: EuUser }
| { key: "us"; value: UsUser }
| { key: "apac"; value: ApacUser }

Narrow the key and TypeScript narrows the value:

if (winner.key === "eu") {
  winner.value; // EuUser
} else if (winner.key === "us") {
  winner.value; // UsUser
} else {
  winner.value; // ApacUser
}

Semantics

race() intentionally follows the mental model of Promise.race().

  • Every task starts in object property order before any settlement is processed.
  • The first task to settle wins, whether it fulfils or rejects.
  • A first rejection rejects with the original reason; it is never wrapped.
  • A synchronous task result works. A synchronous throw becomes a rejected race.
  • race({}) is rejected at the call site with a clear TypeError; TypeScript also rejects it.
  • Only own, enumerable string keys are considered.

There is no priority system. Property order defines predictable startup order only—not a tie-breaker.

Optional loser cancellation

By default, losers keep running, exactly like Promise.race():

const winner = await race({
  eu: ({ signal }) => fetch("https://eu.example.com/user/42", { signal }),
  us: ({ signal }) => fetch("https://us.example.com/user/42", { signal }),
});

Set abortLosers: true to abort every still-running loser after the first settlement, including when the winning task rejects:

const winner = await race(
  {
    primary: ({ signal }) => fetch("/primary", { signal }),
    backup: ({ signal }) => fetch("/backup", { signal }),
  },
  { abortLosers: true },
);

Cancellation is cooperative. Passing a signal does not magically stop synchronous CPU work or code that ignores it. fetch, many database clients, and your own signal-aware functions can react to it.

The winning task's signal is never aborted by abortLosers.

External cancellation

Pass a caller-owned signal to stop a pending race:

const controller = new AbortController();

const winner = await race(
  {
    primary: ({ signal }) => fetch("/primary", { signal }),
    backup: ({ signal }) => fetch("/backup", { signal }),
  },
  { signal: controller.signal, abortLosers: true },
);

controller.abort(new Error("User navigated away"));

If the external signal is already aborted, no task starts. If it aborts while the race is pending, every task signal is aborted and the returned promise rejects with the exact signal.reason.

Once a task wins, the external listener is removed. With the default abortLosers: false, an external abort later does not retroactively cancel a loser that was deliberately allowed to continue.

Options

interface RaceOptions {
  signal?: AbortSignal;
  abortLosers?: boolean; // false by default
}

Public types

import type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks } from "better-race";

RaceContext deliberately contains only signal. There are no framework adapters, schedulers, retries, timeouts, hooks, or hidden global state.

raceUntil(tasks, options)

raceUntil() starts every task concurrently, but settles only when a fulfilled value passes accept. It is for “first usable result” cases where an early null, stale response, or unsuitable value should not end the race. It is not a replacement for a normal cache-first lookup: use it only when starting every candidate is an intentional latency or resilience trade-off.

Keyed, cancellable Promise.any()

Use an always-accepting predicate when the first fulfilled result should win. In that mode, raceUntil() is a keyed, cancellable alternative to Promise.any(): rejections are skipped while another task can still fulfil, and the winner keeps its task key.

const winner = await raceUntil(
  {
    cache: () => readCache(),
    api: ({ signal }) => fetchUser({ signal }),
  },
  { accept: () => true, abortLosers: true },
);

if (winner.key === "api") {
  winner.value; // User
}

If every task rejects, raceUntil() rejects with NoAcceptedResultError, which extends AggregateError. Unlike native Promise.any(), its rejections property retains each task key alongside the original rejection reason.

import { raceUntil } from "better-race";

type User = { id: string };

const winner = await raceUntil(
  {
    memory: () => memoryCache.get("user-42"), // User | null
    redis: () => redisCache.get("user-42"), // User | null
    database: ({ signal }) => fetchUser("user-42", { signal }), // User
  },
  {
    accept: (value): value is User => value !== null,
    abortLosers: true,
  },
);

winner.key; // "memory" | "redis" | "database"
winner.value; // User

Semantics

  • All tasks start in object property order before any fulfilment, rejection, or acceptance is processed.
  • A fulfilled value for which accept(value) is false is declined; pending tasks keep racing.
  • A task rejection is recorded and ignored while another task could still provide an accepted value.
  • The first accepted value wins. With abortLosers: true, every pending loser receives an abort signal; otherwise it keeps running.
  • If every task settles without an accepted value, raceUntil() rejects with NoAcceptedResultError.
  • If accept throws, raceUntil() rejects with that exact error. External abort behaves exactly like race() and aborts all pending tasks with the caller’s original signal.reason.

NoAcceptedResultError extends AggregateError. Its errors array retains original rejection reasons, and its rejections property keeps each reason coupled to the task key:

try {
  await raceUntil(tasks, { accept: isUsable });
} catch (error) {
  if (error instanceof NoAcceptedResultError) {
    error.rejections; // readonly { key: string; reason: unknown }[]
  }
}

Type narrowing

Use an explicit type predicate when the result must be narrowed on TypeScript 5.4 and newer:

accept: (value): value is User => value !== null;

A plain boolean callback is always valid, but preserves each task’s original value type. The keyed relationship remains intact in both forms.

Public types

import {
  NoAcceptedResultError,
  raceUntil,
  type RaceUntilOptions,
  type RaceUntilRejection,
  type RaceUntilResult,
} from "better-race";

Use cases

  • Query independent read replicas when lower tail latency is worth redundant read work.
  • Query independent replicas and return the fastest response.
  • Race a preferred endpoint against a fallback endpoint, then abort the fallback.
  • Preserve source information for metrics, tracing, or structured logging.
  • Write compact TypeScript that narrows the result without hand-written wrapper objects.
  • Query several stores in parallel until one returns a usable, non-null record.

Examples

The executable examples are in examples/:

CI compiles and executes these examples against the packed package, not the source tree.

Visual semantics

A race is not a dependency graph: every task starts immediately. The important moment is the first settlement.

Race Timeline — EU replica wins with abortLosers: true

Task       │ 0ms                 72ms                                  400ms
───────────┼─────────────────────┼──────────────────────────────────────────
eu         │ ███████████████████ ● fulfilled winner
us         │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
apac       │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
                              ↑
                       first settlement wins

Legend: █ active fulfilled work · ▓ active rejected work · ▒ active work aborted by the race

raceUntil() keeps the same concurrent start, but the line marks the first accepted value rather than the first settlement:

RaceUntil Timeline — wait for an accepted result

Task       │ Outcome          │ Decision                    │ Timeline
───────────┼──────────────────┼─────────────────────────────┼────────────────────────────────────
memory     │ null @ 24ms      │ accept → false · decline    │ ███ ▧ declined
redis      │ Error @ 68ms     │ record rejection · continue │ ████████ ▓ recorded
database   │ User @ 176ms     │ accept → true · winner      │ ██████████████████████ ● accepted
backup     │ pending @ 176ms  │ abortLosers → abort          │ ██████████████████████ ▒ cancelled

Legend: █ active task work · ▧ fulfilled candidate declined by accept · ▓ recorded rejection · ● accepted winner · ▒ cancelled loser

Open playground/race-lab.html directly in a browser to explore an animated version. It contains concrete race() and raceUntil() scenarios; the page is a visual prototype, not a shipped package artifact. Vitest and the packed-consumer test verify the runtime contract.

Quality checks

The CI workflow runs the V8 coverage report on Node 22, 24, and 26. The configured threshold is exactly 100% for statements, branches, functions, and lines, so a green CI run is a verifiable guarantee rather than a decorative badge. Open the latest Node 22 job to inspect its full report.

Development

npm install
npm run verify

verify runs formatting, linting, typechecking, 100% runtime coverage, type tests, a production build, and a packed-consumer test.

Useful commands:

npm run format        # Format source with oxfmt
npm run lint          # Lint with oxlint
npm run test:coverage # Runtime tests with strict 100% coverage
npm run test:types    # Compile-time inference tests
npm run build         # ESM package and declaration output
npm run test:pack     # Test the tarball from a clean consumer

Husky runs formatting for staged files plus typechecking and tests before commits. Commit messages use the Conventional Commits specification.

Contributing and security

See CONTRIBUTING.md and SECURITY.md. This project is released under the MIT License.