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.
