•5 min read

Promise.all in React: The Error Boundary That Never Fires

Three weeks ago our ops dashboard rendered as a white rectangle. Not the browser error page, not our fallback UI with the sad cloud drawing on it. The page shell, three empty panel frames, and a spinner that kept spinning until I closed the tab.

The console was less blank. One line, from the orders panel: Uncaught (in promise) TypeError: Failed to fetch. That rejection came out of a Promise.all that had taken the other two panels down with it, even though both of them had their data sitting in memory, fully loaded.

What made it a bad afternoon is that the whole page sits inside an error boundary. It never fired. Here is why, and what I changed afterwards.

A boundary watches render, not promises

Error boundaries catch errors raised while React renders, and errors from lifecycle methods. The React docs list the gaps directly: event handlers, server rendering, errors thrown inside the boundary itself, and asynchronous code are all outside its reach.

My fetch lived in a useEffect, so its rejection landed long after React had finished the commit. Nothing was watching by then. The browser logged an unhandled rejection and went back to sleep.

Then I found the exception, which cost me most of a day. If the async function is passed to startTransition, the boundary does catch it, and the useTransition reference covers the case: a function passed to startTransition that throws or returns a rejected promise hands the error to the nearest boundary, which displays its fallback.

// the boundary never fires
useEffect(() => { loadPanels() }, [])

// the boundary fires
const [isPending, startTransition] = useTransition()
const refresh = () => startTransition(async () => { await loadPanels() })

Same fetch, same failure, two very different outcomes. I had used the first form in nine places.

Promise.all keeps the promises it can no longer use

One panel's failure blanked three panels because of the aggregate itself:

const [user, orders, metrics] = await Promise.all([
  fetchUser(),
  fetchOrders(),
  fetchMetrics(),
])

Promise.all rejects the moment any input rejects, and the reason you catch is that single error, not a bundle of them. Aggregate errors belong to Promise.any, a different function with different semantics. I had been assuming the two were interchangeable.

Two details I only settled by running it. The error you catch is the first promise to settle, which is not necessarily the first one in the array. Nothing is canceled either: the other requests keep running to completion and their results are thrown away.

$ node promise-check.mjs
rejected with: Error (c-down)
has .errors: false
finished anyway: [ 'slow-ok' ]

So the user stares at an empty screen while the API does all of the work anyway. Worst of both options.

allSettled, and the destructuring I got wrong

My first fix was to swap in Promise.allSettled and leave the await line untouched. It compiles. That is the problem.

const [user, orders] = await Promise.allSettled([fetchUser(), fetchOrders()])
// user is { status, value } or { status, reason }, not the user object

You get a wrapper per promise now. user.value is the record when things work and undefined when they do not, so a broken panel renders with blank fields instead of an error. No exception, no log line, no boundary. I had moved the failure from loud to silent, which is the worse of the two.

There is a second trap waiting when you try to debug one of these. On a rejected result the reason is a real Error object, and JSON.stringify on an Error prints an empty object, so logging the serialized results hands you {"status":"rejected","reason":{}} and tells you nothing useful. Log the array itself and let the devtools render the Error.

allSettled rejects only when the iterable you hand it throws, which never happens for a literal array of promises. That is the trade you are making: it will not throw at you, so you have to look.

fetch treats a 500 as a success

The other half of this bug is the gap between a failed request and a rejected promise. MDN states it plainly: a fetch() promise rejects only when the request itself fails, for a malformed URL or a network error. A response with an error status code resolves normally.

const res = await fetch('/api/orders')
console.log(res.ok, res.status)  // false 500, no exception thrown

Which means Promise.all can resolve cleanly while all three panels are broken, and the only symptom is undefined where the numbers should be. It gets better: calling res.json() on an HTML error page throws a SyntaxError, and that message sends you hunting for a parsing bug that does not exist. Check the status before you parse.

async function json(url) {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`${url} returned ${res.status}`)
  return res.json()
}

The shape I ship now

A dashboard is three independent panels, so I stopped aggregating it into one promise. Each panel resolves to its own result and renders or fails on its own:

async function attempt(fn) {
  try {
    return { ok: true, data: await fn() }
  } catch (error) {
    return { ok: false, error }
  }
}

const [user, orders, metrics] = await Promise.all([
  attempt(() => json('/api/user')),
  attempt(() => json('/api/orders')),
  attempt(() => json('/api/metrics')),
])
A sharp analytics dashboard on one screen with a second dashboard blurred behind it

Three settled results, no wrapper objects to unwrap at the call site, and a single field to branch on when each panel decides what to draw. The orders panel shows a retry button while the other two render normally, which was what I wanted on day one.

Promise.all still has a job. When the screen really is one unit, meaning a checkout submit that has to write an order and a payment together, failing fast is correct and half-rendered UI is a lie. Aggregate exactly as far as the UI aggregates, and no further.

A couple of things came out of this. The attempt helper went into Snippet Ark so I stop retyping it, and if your panels are slow rather than broken, the dashboard performance post is the next read. Cancellation is a separate problem with AbortController.