skip to content

Promise.allSettled never rejects. Given that, what must your code do with the array it fulfils with, and what bug appears if you treat those entries as if they were plain resolved values?

level: middleimportance: should knowfreq 52%

answer

  1. failure became data, not an exception
  2. status first, value second
  3. missing key reads as undefined
  4. catch block around it is dead code
  5. zero successes still fulfils

basics

~20 s

You must inspect each entry's status yourself: fulfilled entries carry value, rejected ones carry reason and no value at all. Treating the array as plain values yields undefined for every failure, and a try/catch around the await never fires, so failures pass silently.

solid answer

~50 s

Because the aggregate always fulfils, nothing throws on your behalf — the failure information lives in the data, not in the control flow. Each element is `{ status: 'fulfilled', value }` or `{ status: 'rejected', reason }`, and the other property is simply absent, so `results.map(r => r.value)` quietly produces `undefined` for every failed entry. That `undefined` then flows into your rendering or aggregation as if it were a real result. The fix is to branch on `status` explicitly: filter fulfilled entries for the values you use, filter rejected ones to log their `reason` and drive a degraded state, and decide deliberately what happens when *every* entry rejected — often that should still be an error you throw. Wrapping `await Promise.allSettled(...)` in `try/catch` gives false confidence, since the catch block can never run for a task failure.

code

javascript · 15 lines
javascript
const tasks = [
  Promise.resolve('a'),
  Promise.reject(new Error('boom')),
  Promise.resolve('c'),
];

const results = await Promise.allSettled(tasks);

console.log(results.map(r => r.value)); // ['a', undefined, 'c']  <-- silent hole

const values = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const reasons = results.filter(r => r.status === 'rejected').map(r => r.reason);

console.log(values);  // ['a', 'c']
console.log(reasons); // [Error: boom]

go deeper

for a junior

Remember the two descriptor shapes and always check status before touching value or reason. Know that a rejected entry has no value property, so reading it gives undefined rather than an error.

for a middle

Explain that allSettled converts failure from control flow into data, so try/catch around the await is dead code and the missing-key read silently yields undefined. Be able to write the filter-and-log split from memory.

for a senior

Treat logging the reasons as mandatory rather than optional: with allSettled nothing reaches your global error reporting, so a dependency can fail continuously behind healthy-looking responses. Define the all-failed policy so a total outage does not render as an empty result.

for a principal

Set the house rule for what a partial-result response looks like — per-entry status in the payload, a metric on the rejected count, and an explicit escalation threshold — so that using allSettled cannot quietly degrade a product surface without anyone noticing.

## The information moved from control flow to data With `Promise.all`, failure arrives as an exception: the `await` throws, your `catch` runs, and you cannot ignore it by accident. `Promise.allSettled` deliberately gives up that property. It fulfils no matter what, so failure is now a *value* in an array that you must go and look at. Forgetting to look is the single most common bug with this combinator, and it fails silently — the worst kind. ## The descriptor shape ```js const results = await Promise.allSettled([ok(), boom(), ok2()]); // [{ status: 'fulfilled', value: 'a' }, // { status: 'rejected', reason: Error('boom') }, // { status: 'fulfilled', value: 'c' }] ``` Two details matter. First, `status` is one of exactly two strings, `'fulfilled'` or `'rejected'`. Second, the unused property is **not present** — a rejected descriptor has no `value` key at all, and a fulfilled one has no `reason` key. Because property access on a missing key yields `undefined` rather than throwing, the mistake is invisible: ```js const values = results.map(r => r.value); // ['a', undefined, 'c'] ``` That `undefined` now travels. It renders as an empty cell, sums as `NaN`, is written to a cache as a legitimate miss, or serializes to `null` in a JSON response where the consumer reads it as "the server says there is nothing here" rather than "the server could not find out". ## Reading it correctly Branch on `status` and keep the two groups separate: ```js const results = await Promise.allSettled(ids.map(id => fetchRow(id))); const rows = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const failures = results .map((r, i) => (r.status === 'rejected' ? { id: ids[i], reason: r.reason } : null)) .filter(Boolean); for (const f of failures) logger.warn({ id: f.id, err: f.reason }, 'row failed'); ``` The index correspondence is what makes the second block possible: `results[i]` always describes the input at position `i`, so you can attribute each failure to a specific identifier without embedding it in the error. ## Decide the all-failed policy explicitly `allSettled` treats zero successes exactly like partial success — it just hands you an array where every entry is rejected. Almost always that should not be reported to the caller as a normal empty result: ```js if (failures.length === results.length && results.length > 0) { throw new AggregateError(failures.map(f => f.reason), 'all fetches failed'); } ``` `AggregateError` is a standard error type (ES2021) that carries an `errors` array, which makes it a natural container when you want to escalate several reasons at once. Deciding this policy is part of using `allSettled`; leaving it undecided is how a total outage renders as an empty dashboard rather than an error page. ## The try/catch trap ```js try { const results = await Promise.allSettled(tasks); // never throws for a task failure render(results.map(r => r.value)); // undefined for failures } catch (e) { // effectively dead code } ``` This reads like careful error handling and provides none. The `catch` can only fire for a programmer error such as passing a non-iterable, or for a throw inside the `render` call itself. Reviewers should treat a bare `try/catch` around `allSettled` with no `status` inspection as a defect. ## Observability Because nothing propagates, nothing reaches your global error reporting either. If you never log `reason`, a dependency can be failing one hundred percent of the time while every request returns 200 with a slightly emptier payload. Whenever you choose `allSettled`, pair it with an explicit log or metric on the rejected entries — that logging *is* the error handling you gave up when you stopped letting the rejection propagate.

  • How do you tell which input a rejected entry came from?
    By index — `results[i]` always describes the entry at position `i` of the iterable you passed, so you zip the result array against your input array. `results.map((r, i) => ({ input: inputs[i], r }))` gives you the pairing. Relying on the error message to identify the item is fragile; the positional guarantee is part of the specified behaviour.
  • What should happen when every entry in an allSettled result rejected?
    Decide it explicitly, because the combinator will not. Usually a total failure should not be reported as a successful empty result, so throw — `AggregateError` (ES2021) is a good fit since it carries an `errors` array of all the reasons. Silently returning an empty list turns a full outage into a page that merely looks quiet.

saying these in an interview costs you the question

  • Reads r.value without checking r.status first
  • Wraps await Promise.allSettled in try/catch as error handling
  • Thinks a rejected entry has value set to null
  • Assumes failures still reach the global rejection handler
  • Treats zero successes as a normal empty result

context