Promise Combinators: What Fails First
Two probes on fixed shapes: all stops at the first rejection, allSettled reports every outcome in input order, any aggregates errors, and an empty race never settles.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of which promise settled first and which one never settled at all, but it cannot change how a runtime orders settlements, cannot turn a rejection into a resolution, and cannot make a promise with no input settle. This review runs two probes over ten measured cases and records what each of the four combinators does when one input rejects, when the inputs finish in a different order than they were given, when every input rejects, when the input list is empty, and when a rejection is never handled.
TL;DR
Promise.allrejects with the first rejection and stops waiting; the probe printsfirst; delivered_so_far=0.- The other inputs keep running after that rejection, so the next line shows
delivered=slow,later. Promise.allSettledwaits for every input and keeps input order, so a slow fulfilment is reported first and a fast one last.Promise.allalso keeps input order, soa,b,carrives in that order even thoughbfinished first.Promise.anyresolves on the first fulfilment; when every input rejects it throws anAggregateErrorwhoseerrorsarray keeps input order.Promise.racetakes the first settlement, so a fast rejection beats a slower fulfilment.- An empty input is where the four diverge:
allandallSettledfulfil,anyrejects withAggregateError, andracenever settles. - A rejection nobody handles is reported to the runtime's unhandled-rejection hook, which the probe observes as
orphan.
Contents
- What each combinator promises about completion
- Why Promise.all stops at the first rejection
- How allSettled reports every outcome in input order
- Where any differs from race
- What an empty input does to each combinator
- How a rejection nobody handles surfaces
- What a settled combinator leaves running
- Record a combinator reading
What each combinator promises about completion
The four combinators take the same input, an iterable of values and promises, and differ only in which settlement they deliver to the caller. That single difference decides every failure mode below, so it is worth stating the four contracts in one place before reading any output.
Promise.all fulfils when every input fulfils, with an array in input order, and rejects as soon as one input rejects. Promise.allSettled fulfils when every input settles, with one record per input in input order, and never rejects because an input rejected. Promise.any fulfils on the first fulfilment and rejects only after every input has rejected, with an AggregateError. Promise.race settles with whichever input settles first, fulfilment or rejection, and has no opinion about the rest.
Two consequences follow directly. First, only all and race can reject because of a single input; allSettled cannot reject for that reason at all, and any rejects only when the last input rejects. Second, three of the four preserve input order in their result while race returns a single value with no array to order, which is why a caller that needs to know which input won has to attach the label to the input itself rather than to the result.
The probe drives each contract with the same three inputs, a slow fulfilment, a rejection, and a fast fulfilment, and labels the outcome. Nothing in the probe depends on a particular delay beyond the relative order of the three timers, so the shape can be reproduced on any runtime that implements the four methods.
node=v26.9.0
all-rejects-early -> first; delivered_so_far=0
all-later-resolution-still-runs -> delivered=slow,later
allSettled-order -> slow,boom,fast
all-input-order -> a,b,c
any-first-fulfilled -> fast
any-all-reject -> AggregateError; errors=e1,e2
race-first-settlement-wins -> fast-reject
empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending
unhandled-rejection-observed -> orphan
non-promise-input -> [1,"two",{"three":3}]
The last line is the quiet one. Promise.all accepts plain values, so a list that mixes numbers, strings and objects resolves with those values unchanged, and no input has to be a promise for the combinator to work. A caller that wraps every value in Promise.resolve() before passing it does work the combinator already does.
Why Promise.all stops at the first rejection
Promise.all settles at the moment its first input rejects, and the value it delivers is that rejection's reason, not an array. The probe catches it and reports both the message and how many other inputs had delivered by then:
all-rejects-early -> first; delivered_so_far=0
The count is zero because the rejection arrived before the ten millisecond fulfilment. The important half of that line is what it does not say: the combinator gave up, and the inputs did not. Every input that was already running keeps running to its own end, which the probe checks by waiting and then printing what arrived afterwards:
all-later-resolution-still-runs -> delivered=slow,later
Both remaining inputs finished, one of them long after the rejection that ended the wait. This is the failure mode that surprises callers most often. A rejected Promise.all does not cancel its inputs, because a promise has no cancel operation in the language; the combinator simply stops observing them. Work the caller wanted to stop has to be stopped by the code that owns it, usually through an AbortController passed to each operation, and the combinator has no part in that.
The same behaviour sets the expectation for cleanup. A finally block on the Promise.all call runs when the combinator settles, which is early in the rejecting case, so cleanup written there runs while the other inputs are still in flight. A caller that closes a shared resource in that block can close it underneath work that is still using it. The safe order is to stop the inputs first, wait for them to acknowledge the stop, and only then release the resource.
Promise.all does preserve input order when it fulfils, and it does so even though the inputs finish in a different order. The probe gives three fulfilments that finish at twenty-five, five and fifteen milliseconds and reads back the array:
all-input-order -> a,b,c
The fastest input was b, and the array still starts with a. A caller that pairs results with requests by index is therefore safe, and a caller that wants completion order has to record it inside each input rather than infer it from the result.
How allSettled reports every outcome in input order
The caller of Promise.allSettled gets no exception from a failing input at all. Each record carries a status of fulfilled or rejected, plus either a value or a reason, so a failure arrives as an entry rather than as a thrown error. The probe passes the same three shapes and prints the messages in the order they come back:
allSettled-order -> slow,boom,fast
The slow fulfilment is first because it was the first input, the rejection is second because it was the second input, and the fast fulfilment is last even though it finished first of all three. This is the property that makes allSettled the right choice for a batch whose failures should not hide its successes: the caller gets a complete report, and each entry stays attached to the input it came from.
The cost is time. allSettled cannot settle before the slowest input settles, so a batch with one input that never settles never produces a report at all. The probe's inputs all finish, which hides that risk; a caller that needs a bound has to race the allSettled call against a timer and treat a timeout as a partial report, which means the caller also has to decide what to do about the input that is still running. The entries themselves are plain objects, so structuredClone versus JSON explains which of them survive a copy into a log record and which one is silently dropped.
There is a second difference worth naming. allSettled reports a rejection as data, so a rejected entry does not become an exception unless the caller throws it. A caller that logs the report and moves on has handled every failure by construction, and a caller that wants the older all behaviour back has to scan the array and throw the first rejection it finds. The two combinators are the same computation with two policies about what to do with a failure, and the policy lives in the caller.
Where any differs from race
Promise.any and Promise.race both take several inputs and both return one outcome, and they differ in which settlement they accept. any ignores rejections until every input has rejected, so it resolves with the first fulfilment even if a rejection arrives first. race accepts the first settlement of either kind, so a rejection that arrives first wins. The probe shows both:
any-first-fulfilled -> fast
race-first-settlement-wins -> fast-reject
The any case had a slow fulfilment and a fast fulfilment, and it resolved with the fast one, which is what the name suggests. The race case had a slow fulfilment and a fast rejection, and it rejected. A caller that reaches for race to implement a timeout therefore gets a rejection when the timeout fires, which is usually what the caller wants, and a caller that reaches for any to implement "whichever mirror answers first" gets a fulfilment, which is also usually what the caller wants. Swapping them changes the failure semantics of the whole call.
When every input of any rejects, the combinator rejects with an AggregateError whose errors array holds the reasons in input order:
any-all-reject -> AggregateError; errors=e1,e2
That array is the reason any is worth using over a manual loop. A manual loop that keeps the last rejection loses the earlier reasons, while AggregateError keeps all of them and gives the caller a stable order to report in. A caller that logs error.message alone sees a generic message and loses the detail; the useful field is error.errors.
race has no such aggregate, because it settles on one input and never learns about the rest. A race that rejected tells the caller nothing about the other inputs, and a caller that needs to know whether they also failed has to inspect them separately. That asymmetry is not a defect in either method; it is the difference between a method that waits for a category of settlement and one that takes the first of any kind.
What an empty input does to each combinator
An empty iterable is the smallest input the four methods accept, and it is the case where their contracts stop looking similar:
empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending
Promise.all([]) fulfils immediately with an empty array, because the condition "every input has fulfilled" is already true when there are no inputs. Promise.allSettled([]) does the same and returns an empty array, so a caller that sums a report over an empty batch gets zero rather than an error. Promise.any([]) rejects with an AggregateError whose errors array is empty, because the condition "at least one input fulfilled" can never become true. Promise.race([]) returns a promise that never settles, because no input can settle it.
The race case is the one that can hang a caller for good. The probe cannot await that promise directly, so it races it against a timer and prints still-pending when the timer wins, which is the only way to observe a promise that has no settlement. A caller that builds its input list from a filter, and that filter can return nothing, has a code path that never resolves. The fix is to check the length before calling race, or to always include at least one input that is guaranteed to settle, such as a timer.
The four outcomes follow from the contracts in the first section once the empty case is read as a limit. all needs every input to fulfil and has nothing to wait for, allSettled needs every input to settle and has nothing to wait for, any needs one fulfilment and can never get one, and race needs one settlement and can never get one. Nothing about the empty case is special-cased in the specification; it falls out of the definitions, which is why a caller who reasons from the definitions predicts all four correctly.
How a rejection nobody handles surfaces
A rejection that no handler is attached to does not disappear. The runtime reports it to whatever is listening for unhandled rejections, and the probe listens for exactly one turn to observe it:
unhandled-rejection-observed -> orphan
The probe attaches a listener, creates a rejected promise without a catch, waits thirty milliseconds, and removes the listener. The message arrives, which means the rejection was still unhandled when the turn ended. In a browser the same event is unhandledrejection on the window, and in Node it is the process event the probe uses, so the shape of the observation is the same even though the API name differs.
A combinator does not create that situation, and the probe checks it directly. A second probe settles a Promise.race on a fulfilment and then lets a slower input reject, and separately lets a Promise.all reject early while another input rejects later. Neither later rejection reaches the unhandled-rejection listener, while a bare rejection with no handler does:
after-race-fail=false
after-all-fail=false
control-fail=true
The reason is that each combinator attaches its own handler to every input as it is passed in, so an input that rejects after the combinator settled is still observed, just discarded. That is a real difference from the naive expectation: abandoning a batch does not spray unhandled rejections, and a runtime that stays quiet after a failed batch is telling the truth.
What the caller still has to do is keep the outcome. The combinator drops the later settlement, so a rejection that arrives after the fact is lost unless the input records it itself. A catch on each input that logs the failure and returns a sentinel keeps that record and leaves the combinator's semantics intact, and it costs one line per input. A caller that skips it gets a clean result and no trace of the abandoned failure at all, which is quieter and harder to debug than an unhandled rejection would have been.
What a settled combinator leaves running
The probe's second line is the evidence for a claim that is easy to state and hard to remember in a code review: a settled combinator has stopped watching, not stopped work. Three inputs were started, one rejected at once, and both of the others still reached their own end. The combinator's promise was settled the whole time.
That gap has a cost in a batch that shares a resource. Ten requests that share one connection pool, one file handle or one rate-limit budget will all keep consuming it after the combinator has already rejected, because each one is still inside its own operation. The budget that the caller planned for one batch is spent by a batch that the caller already abandoned, and the next batch starts with less than it expects.
The way to close the gap is to hand each input a way to stop. An AbortController per batch, with its signal passed to every operation, turns the combinator's early settlement into an explicit stop:
const controller = new AbortController();
try {
await Promise.all(tasks.map(task => task.run(controller.signal)));
} catch (error) {
controller.abort();
throw error;
}
The abort call happens in the same turn as the rejection, so the remaining inputs learn about it on their next check rather than at their natural end. That is the difference between a batch that stops and a batch that merely stops being awaited, and it is the only part of the pattern the combinator does not do for the caller.
The same reasoning applies to race. A race used as a timeout settles on the timer, and the real work keeps running with nobody awaiting it, so a caller that aborts the controller when the timer wins gets both halves right: the deadline is enforced and the abandoned work is stopped. A caller that only races and moves on has enforced the deadline and left the work behind. The turn in which that timer fires is itself an ordering question, which microtasks and timers covers.
Record a combinator reading
A combinator reading supports a narrow claim: this runtime, these inputs, this arrival order, produced these settlements. Five fields keep it checkable:
- The runtime, because the unhandled-rejection event and the
AggregateErrorshape are both runtime-provided. - The combinator and the input count, because the empty case and the single-input case behave differently from the general one.
- The arrival order of the inputs, because
allandallSettledreport input order and a record without it cannot explain why a slow input came first. - Which inputs were still running when the combinator settled, because that is the difference between a stopped wait and stopped work.
- Whether a rejection was handled, because an unhandled one appears in a different channel from the result.
Public sources
- MDN: Promise.all()
- MDN: Promise.allSettled()
- MDN: Promise.any()
- MDN: Promise.race()
- MDN: AggregateError
- Node.js: unhandledRejection event
- BotBrowser: automation consistency reference
Practical conclusion
Choose the combinator by the failure policy the caller needs, not by the shape of the input: all when one failure should end the wait, allSettled when every outcome should be reported, any when one success is enough, and race when the first settlement of any kind is the answer. Remember that a settled combinator does not stop its inputs, so stop them yourself and handle each rejection you are about to stop watching. Guard the empty list before calling race, because a race over no inputs never settles. For the order those settlements arrive in, read microtasks and timers; for what survives a copy of the data a settled promise carries, read structuredClone versus JSON.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Microtasks and Timers: The Order Your Code Actually Runs
PlatformDRM Capability Checks: Key System, Robustness, Output
FingerprintUnicode Normalization: Four Text Identities a Page Reads
FingerprintObject Key Order: Integer Keys Sort, String Keys Stay
FingerprintstructuredClone vs JSON: What a Deep Copy Actually Keeps
FingerprintICU Timezone Aliases and the TZ You Actually Report
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.