The JavaScript Promise API Has Features You’ve Never Used — And They’re Exactly What You Need

Promise.any, Promise.allSettled, Promise.race with AbortController, unhandledRejection trapping, Promise combinators for rate-limited APIs, and the upcoming Promise.withResolvers() — the complete Promise toolkit that goes way beyond async/await.


Most JavaScript developers use three things from the Promise API: new Promise(), async/await, and occasionally Promise.all(). The rest of the API exists, ships in every modern browser and Node.js runtime, and solves problems that developers routinely solve with more complex custom code.

Promise.any — the one that fulfils on the first success instead of failing on the first failure — eliminates entire try/catch retry loops. Promise.allSettled — the one that waits for all promises regardless of success or failure — replaces the .catch() gymnastics in bulk operations. Promise.withResolvers() — the one that exposes resolve and reject externally — collapses a common pattern from 5 lines to 1.

This post covers the full Promise toolkit. Not theoretical — real patterns you can use today.


The Four Promise Combinators: When to Use Each

The combinator is the strategic choice. Each one has a specific use case:

Promise.all()          → All must succeed. Fail fast on any rejection.
Promise.allSettled()   → Wait for all, regardless of success or failure.
Promise.race()         → First to settle (fulfilled OR rejected) wins.
Promise.any()          → First to FULFIL wins. Ignore rejections unless ALL fail.

Promise.all() — You Know This One, But There’s a Nuance

// The standard use — all or nothing
const [user, orders, preferences] = await Promise.all([
  fetchUser(userId),
  fetchOrders(userId),
  fetchPreferences(userId),
])
// If ANY rejects, the entire Promise.all rejects immediately
// The other fetches continue running but their results are discarded

The Nuance: Promise.all Fails Fast but Doesn’t Cancel

// All three requests fire simultaneously
const [user, orders, preferences] = await Promise.all([
  fetchUser(userId),          // takes 100ms
  fetchOrders(userId),        // takes 300ms
  fetchPreferences(userId),   // fails at 50ms
])
// At 50ms: preferences rejects → Promise.all rejects
// BUT: fetchUser and fetchOrders are still running in the background
// Their network requests are not cancelled
// Their results are silently discarded

If you need proper cancellation on failure:

// Cancel all requests if any fails
async function fetchAllWithCancellation(userId) {
  const controller = new AbortController()
  const { signal } = controller

  try {
    const [user, orders, preferences] = await Promise.all([
      fetchUser(userId,       { signal }),
      fetchOrders(userId,     { signal }),
      fetchPreferences(userId, { signal }),
    ])
    return { user, orders, preferences }
  } catch (err) {
    controller.abort()  // cancel remaining requests on any failure
    throw err
  }
}

Promise.allSettled() — The Bulk Operation Champion

Promise.allSettled() waits for every promise to complete — regardless of success or failure — and returns an array of result objects:

const results = await Promise.allSettled([
  fetch('/api/users/1'),
  fetch('/api/users/2'),
  fetch('/api/users/999'),  // this one returns 404
  fetch('/api/users/4'),
])

// results is an array of:
// { status: 'fulfilled', value: Response }  — for successful ones
// { status: 'rejected',  reason: Error }    — for failed ones

// Process results regardless of individual failures
for (const result of results) {
  if (result.status === 'fulfilled') {
    processUser(result.value)
  } else {
    logFailure(result.reason)
  }
}

The Pattern: Bulk Operations With Partial Success

async function sendBulkEmails(emails) {
  const sendResults = await Promise.allSettled(
    emails.map(email => sendEmail(email))
  )

  const summary = {
    sent:   sendResults.filter(r => r.status === 'fulfilled').length,
    failed: sendResults.filter(r => r.status === 'rejected').length,
    errors: sendResults
      .filter(r => r.status === 'rejected')
      .map((r, i) => ({ email: emails[i], error: r.reason.message })),
  }

  return summary
  // Returns: { sent: 47, failed: 3, errors: [{email: '...', error: '...'}] }
  // Instead of: throwing on the first failed email and sending nothing
}

The TypeScript Helper That Makes allSettled Cleaner

function partition<T>(
  results: PromiseSettledResult<T>[]
): [T[], unknown[]] {
  const fulfilled: T[]       = []
  const rejected:  unknown[] = []

  for (const result of results) {
    if (result.status === 'fulfilled') {
      fulfilled.push(result.value)
    } else {
      rejected.push(result.reason)
    }
  }

  return [fulfilled, rejected]
}

// Usage
const results          = await Promise.allSettled(operations)
const [successes, failures] = partition(results)

console.log(`${successes.length} succeeded, ${failures.length} failed`)

Promise.race() — The Timeout Pattern and Beyond

Promise.race() resolves or rejects as soon as the first promise settles:

// The canonical timeout pattern
async function fetchWithTimeout(url, timeoutMs = 5000) {
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
  )

  return Promise.race([
    fetch(url),
    timeoutPromise,
  ])
}

Though in 2026, AbortSignal.timeout() is the cleaner native approach:

// Native timeout — no race() needed
const response = await fetch(url, {
  signal: AbortSignal.timeout(5000)
})

The More Interesting race() Use Case: First Response From Multiple Sources

// Try three CDN endpoints — use whichever responds first
async function fetchFromFastestCDN(resource) {
  return Promise.race([
    fetch(`https://cdn1.example.com/${resource}`),
    fetch(`https://cdn2.example.com/${resource}`),
    fetch(`https://cdn3.example.com/${resource}`),
  ])
  // Fastest CDN wins — the other two requests continue but are ignored
}

race() for Cache vs Network

// Show cached data immediately if available, update with fresh data when it arrives
async function getCachedOrFresh(key, fetchFn) {
  const cached = getFromCache(key)

  const fresh = fetchFn().then(data => {
    setInCache(key, data)
    return data
  })

  if (cached) {
    // Return cached immediately, but also start the fresh fetch
    // Race: if fresh data arrives before a 500ms threshold, use it instead
    return Promise.race([
      fresh,
      new Promise(resolve => setTimeout(() => resolve(cached), 500))
    ])
  }

  return fresh
}

Promise.any() — The First-Success Combinator

Promise.any() is the inverse of Promise.all() for failures: it fulfils as soon as any promise fulfils, and only rejects if ALL promises reject.

// ✗ The manual retry pattern most developers write
async function fetchWithFallback(url) {
  const providers = ['https://api1.example.com', 'https://api2.example.com', 'https://api3.example.com']

  for (const provider of providers) {
    try {
      return await fetch(`${provider}/${url}`)
    } catch {
      continue  // try next provider
    }
  }
  throw new Error('All providers failed')
}

// ✓ Promise.any — fires all simultaneously, uses the first that succeeds
async function fetchWithFallback(url) {
  return Promise.any([
    fetch(`https://api1.example.com/${url}`),
    fetch(`https://api2.example.com/${url}`),
    fetch(`https://api3.example.com/${url}`),
  ])
  // Returns as soon as the first succeeds
  // If all three fail: throws AggregateError with all three errors
}

The AggregateError on Total Failure

try {
  const response = await Promise.any([
    fetch('https://api1.example.com/data'),
    fetch('https://api2.example.com/data'),
    fetch('https://api3.example.com/data'),
  ])
} catch (err) {
  if (err instanceof AggregateError) {
    console.log('All attempts failed:')
    err.errors.forEach((e, i) => console.log(`  Attempt ${i + 1}: ${e.message}`))
  }
}

The Real-World Pattern: Geographically Distributed APIs

// Users in different regions get faster responses from nearby servers
// Race all regional endpoints — the fastest one wins
async function fetchFromNearestRegion(endpoint) {
  const regions = [
    'https://us-east.api.example.com',
    'https://eu-west.api.example.com',
    'https://ap-south.api.example.com',
  ]

  return Promise.any(
    regions.map(base => fetch(`${base}${endpoint}`).then(r => r.json()))
  )
  // User gets data from the fastest-responding region
  // Other requests are ignored once the first completes
}

Promise.withResolvers() — The Pattern Simplifier

Promise.withResolvers() is one of the most useful recent additions. It creates a promise and returns the { promise, resolve, reject } trio together, eliminating the need for the external variable pattern:

// ✗ The old pattern — resolve/reject defined outside but used inside
let resolve, reject
const promise = new Promise((res, rej) => {
  resolve = res
  reject  = rej
})
// Now promise, resolve, and reject are accessible

// ✓ Promise.withResolvers() — one clean destructuring
const { promise, resolve, reject } = Promise.withResolvers()
// Identical result, one line

Baseline: Widely Available since June 2024 — Chrome 119+, Firefox 121+, Safari 17.4+, Node.js 22+.

The Use Case: Deferred Resolution

// A promise that resolves when an event fires
function waitForEvent(element, eventName) {
  const { promise, resolve, reject } = Promise.withResolvers()

  const onEvent = (event) => {
    element.removeEventListener(eventName, onEvent)
    element.removeEventListener('error',   onError)
    resolve(event)
  }

  const onError = (event) => {
    element.removeEventListener(eventName, onEvent)
    element.removeEventListener('error',   onError)
    reject(new Error(`Error waiting for ${eventName}`))
  }

  element.addEventListener(eventName, onEvent,  { once: true })
  element.addEventListener('error',   onError, { once: true })

  return promise
}

// Usage
await waitForEvent(videoElement, 'canplaythrough')
// Continues when the video is ready to play

The Use Case: Promise-Based Event Bus

class PromiseEventBus {
  private pending = new Map<string, ReturnType<typeof Promise.withResolvers>>()

  wait(eventName: string): Promise<unknown> {
    const deferred = Promise.withResolvers()
    this.pending.set(eventName, deferred)
    return deferred.promise
  }

  emit(eventName: string, value: unknown): void {
    const deferred = this.pending.get(eventName)
    if (deferred) {
      deferred.resolve(value)
      this.pending.delete(eventName)
    }
  }

  fail(eventName: string, error: Error): void {
    const deferred = this.pending.get(eventName)
    if (deferred) {
      deferred.reject(error)
      this.pending.delete(eventName)
    }
  }
}

// Usage
const bus = new PromiseEventBus()

// Somewhere: await the event
const userData = await bus.wait('user:loaded')

// Elsewhere: trigger it
bus.emit('user:loaded', { id: 1, name: 'Taylor' })

Rate-Limited API Calls with Promise Combinators

The real world: you have 1,000 items to process, but the API only allows 10 concurrent requests.

// ✗ Promise.all with 1,000 promises — overwhelms the API
await Promise.all(items.map(item => processItem(item)))

// ✓ Concurrent batch processing with rate limiting
async function processConcurrently(items, concurrency = 10) {
  const results = []

  for (let i = 0; i < items.length; i += concurrency) {
    const batch = items.slice(i, i + concurrency)

    // Process this batch concurrently
    const batchResults = await Promise.allSettled(
      batch.map(item => processItem(item))
    )

    results.push(...batchResults)

    // Optional: small delay between batches to respect rate limits
    if (i + concurrency < items.length) {
      await new Promise(resolve => setTimeout(resolve, 100))
    }
  }

  return results
}

// Usage
const results = await processConcurrently(thousandItems, 10)
// Processes 10 at a time, waits for each batch before starting the next

The Sliding Window Pattern (True Concurrency Cap)

Batching waits for all 10 to finish before starting the next 10. A sliding window keeps exactly N requests in flight at all times:

async function* generateResults(items, concurrency = 10) {
  const executing = new Set()

  for (const item of items) {
    // Create the promise for this item
    const promise = processItem(item).then(result => {
      executing.delete(promise)
      return result
    })

    executing.add(promise)
    yield promise

    // Wait for one to complete if at capacity
    if (executing.size >= concurrency) {
      await Promise.race(executing)
    }
  }

  // Wait for any remaining
  await Promise.allSettled([...executing])
}

// Typed, practical version
async function processWithConcurrencyLimit<T, R>(
  items:       T[],
  processor:   (item: T) => Promise<R>,
  concurrency: number = 10,
): Promise<PromiseSettledResult<R>[]> {
  const results: PromiseSettledResult<R>[] = new Array(items.length)
  const executing = new Set<Promise<void>>()

  for (let i = 0; i < items.length; i++) {
    const index = i
    const task  = processor(items[i])
      .then(value  => { results[index] = { status: 'fulfilled', value } })
      .catch(reason => { results[index] = { status: 'rejected', reason } })
      .finally(() => executing.delete(task))

    executing.add(task)

    if (executing.size >= concurrency) {
      await Promise.race(executing)
    }
  }

  await Promise.allSettled([...executing])
  return results
}

Handling Unhandled Promise Rejections

An unhandled rejection — a promise that rejects without a .catch() or try/catch — can crash Node.js processes and cause silent failures in browsers.

In the Browser

// Global handler for unhandled rejections
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason)
  event.preventDefault()  // prevents the browser from logging it separately

  // Send to your error tracking service
  errorTracker.captureException(event.reason, {
    context: 'unhandledRejection',
    promise: event.promise,
  })
})

// Global handler for rejections that were handled AFTER the fact
window.addEventListener('rejectionhandled', (event) => {
  console.log('Previously unhandled rejection was handled:', event.promise)
})

In Node.js

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason)

  // In production: log to monitoring, then exit gracefully
  errorTracker.captureException(reason)
  process.exit(1)  // or graceful shutdown
})

process.on('rejectionHandled', (promise) => {
  console.log('Rejection was handled late for:', promise)
})

The Fire-and-Forget Anti-Pattern

// ✗ Fire-and-forget — if this rejects, nobody knows
async function sendAnalytics() {
  await fetch('/api/analytics', { method: 'POST', body: data })
}

sendAnalytics()  // no await, no catch — rejection is unhandled

// ✓ Explicitly discard the rejection if you mean to
sendAnalytics().catch(err => {
  // You decided not to await, but at least handle the failure
  console.warn('Analytics failed (non-critical):', err.message)
})

// ✓ Or use a helper that makes intent explicit
function fireAndForget(promise: Promise<unknown>): void {
  promise.catch(err => console.warn('Background task failed:', err.message))
}

fireAndForget(sendAnalytics())

Promise Patterns for Common Real-World Problems

Pattern: Sequential vs Parallel — Know the Difference

// ✗ Sequential — each waits for the previous (total: 300ms)
const user    = await fetchUser(id)     // 100ms
const orders  = await fetchOrders(id)   // 100ms
const prefs   = await fetchPrefs(id)    // 100ms

// ✓ Parallel — all run simultaneously (total: 100ms)
const [user, orders, prefs] = await Promise.all([
  fetchUser(id),    // starts at t=0
  fetchOrders(id),  // starts at t=0
  fetchPrefs(id),   // starts at t=0
])                  // all complete ~100ms later

// ✓ When some depend on others, mix sequential and parallel
const user  = await fetchUser(id)           // must happen first
const [orders, prefs] = await Promise.all([ // can run in parallel
  fetchOrders(user.id),
  fetchPrefs(user.preferenceKey),
])

Pattern: Retry with Exponential Backoff

async function retryWithBackoff<T>(
  fn:          () => Promise<T>,
  maxAttempts: number = 3,
  baseDelay:   number = 1000,
): Promise<T> {
  let lastError: unknown

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      lastError = err

      if (attempt === maxAttempts) break

      const delay = baseDelay * Math.pow(2, attempt - 1)
              + Math.random() * 1000  // jitter prevents thundering herd
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

// Usage
const data = await retryWithBackoff(
  () => fetch('/api/unstable-endpoint').then(r => r.json()),
  3,     // 3 attempts
  500,   // 500ms base delay → 500ms, 1s, 2s (with jitter)
)

Pattern: Promise Queue (One at a Time)

class PromiseQueue {
  private queue:   (() => Promise<unknown>)[] = []
  private running: boolean = false

  enqueue<T>(task: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push(() => task().then(resolve, reject))
      this.process()
    })
  }

  private async process(): Promise<void> {
    if (this.running) return
    this.running = true

    while (this.queue.length > 0) {
      const task = this.queue.shift()!
      await task()
    }

    this.running = false
  }
}

// Usage: ensure API calls happen one at a time
const apiQueue = new PromiseQueue()

// These will execute in order, never concurrently
await apiQueue.enqueue(() => updateUserProfile(profileData))
await apiQueue.enqueue(() => sendNotification(userId))
await apiQueue.enqueue(() => syncToExternalService(userData))

The Promise API Quick Reference

// All four combinators
await Promise.all([p1, p2, p3])
// → [v1, v2, v3] if all fulfil | rejects immediately if any rejects

await Promise.allSettled([p1, p2, p3])
// → [{status, value/reason}, ...] — always resolves, never rejects

await Promise.race([p1, p2, p3])
// → first settled value (fulfilled OR rejected)

await Promise.any([p1, p2, p3])
// → first fulfilled value | AggregateError if all reject

// Constructor patterns
const { promise, resolve, reject } = Promise.withResolvers()
// → deferred promise — resolve/reject accessible externally

const p = Promise.resolve(value)   // instantly fulfilled
const p = Promise.reject(error)    // instantly rejected

// Utility
Promise.resolve(42).then(console.log)   // logs 42
await Promise.resolve()                 // yields to microtask queue (useful for batching)

// Combinator decision guide:
// Need all results?           → Promise.all (fail fast) or Promise.allSettled (all results)
// Need first success?         → Promise.any
// Need first to settle?       → Promise.race
// Control concurrency?        → manual batching with Promise.allSettled
// Need external resolve?      → Promise.withResolvers

Final Thoughts

The Promise API in 2026 is complete, well-supported, and significantly more expressive than async/await alone. The combinators solve real, common problems that developers routinely work around with custom code.

Promise.allSettled is the one developers reach for most once they know it exists — bulk operations with partial failure handling are everywhere. Promise.any eliminates entire fallback patterns. Promise.withResolvers makes deferred promise patterns readable. The rate-limiting patterns solve a problem every API-heavy application encounters.

The gap between async/await and the full Promise API is exactly the gap between “I can write async code” and “I can write async code well.” The full toolkit is available, documented, and already in the runtime you’re using. There’s no reason not to use it.

Leave a Reply

Your email address will not be published. Required fields are marked *