[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-promise-all-error-boundary-not-firing":3},{"content":4,"lastModified":5},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The console was less blank. One line, from the orders panel: \u003Ccode>Uncaught (in promise) TypeError: Failed to fetch\u003C\u002Fcode>. That rejection came out of a \u003Ccode>Promise.all\u003C\u002Fcode> that had taken the other two panels down with it, even though both of them had their data sitting in memory, fully loaded.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>A boundary watches render, not promises\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>My fetch lived in a \u003Ccode>useEffect\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>Then I found the exception, which cost me most of a day. If the async function is passed to \u003Ccode>startTransition\u003C\u002Fcode>, the boundary does catch it, and the \u003Ccode>useTransition\u003C\u002Fcode> reference covers the case: a function passed to \u003Ccode>startTransition\u003C\u002Fcode> that throws or returns a rejected promise hands the error to the nearest boundary, which displays its fallback.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">\u002F\u002F the boundary never fires\nuseEffect(() =&gt; { loadPanels() }, [])\n\n\u002F\u002F the boundary fires\nconst [isPending, startTransition] = useTransition()\nconst refresh = () =&gt; startTransition(async () =&gt; { await loadPanels() })\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Same fetch, same failure, two very different outcomes. I had used the first form in nine places.\u003C\u002Fp>\n\n\u003Ch2>Promise.all keeps the promises it can no longer use\u003C\u002Fh2>\n\n\u003Cp>One panel's failure blanked three panels because of the aggregate itself:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const [user, orders, metrics] = await Promise.all([\n  fetchUser(),\n  fetchOrders(),\n  fetchMetrics(),\n])\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>Promise.all\u003C\u002Fcode> rejects the moment any input rejects, and the reason you catch is that single error, not a bundle of them. Aggregate errors belong to \u003Ccode>Promise.any\u003C\u002Fcode>, a different function with different semantics. I had been assuming the two were interchangeable.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ node promise-check.mjs\nrejected with: Error (c-down)\nhas .errors: false\nfinished anyway: [ 'slow-ok' ]\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>So the user stares at an empty screen while the API does all of the work anyway. Worst of both options.\u003C\u002Fp>\n\n\u003Ch2>allSettled, and the destructuring I got wrong\u003C\u002Fh2>\n\n\u003Cp>My first fix was to swap in \u003Ccode>Promise.allSettled\u003C\u002Fcode> and leave the \u003Ccode>await\u003C\u002Fcode> line untouched. It compiles. That is the problem.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const [user, orders] = await Promise.allSettled([fetchUser(), fetchOrders()])\n\u002F\u002F user is { status, value } or { status, reason }, not the user object\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>You get a wrapper per promise now. \u003Ccode>user.value\u003C\u002Fcode> is the record when things work and \u003Ccode>undefined\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>There is a second trap waiting when you try to debug one of these. On a rejected result the \u003Ccode>reason\u003C\u002Fcode> is a real \u003Ccode>Error\u003C\u002Fcode> object, and \u003Ccode>JSON.stringify\u003C\u002Fcode> on an Error prints an empty object, so logging the serialized results hands you \u003Ccode>{\"status\":\"rejected\",\"reason\":{}}\u003C\u002Fcode> and tells you nothing useful. Log the array itself and let the devtools render the Error.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>allSettled\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>fetch treats a 500 as a success\u003C\u002Fh2>\n\n\u003Cp>The other half of this bug is the gap between a failed request and a rejected promise. MDN states it plainly: a \u003Ccode>fetch()\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const res = await fetch('\u002Fapi\u002Forders')\nconsole.log(res.ok, res.status)  \u002F\u002F false 500, no exception thrown\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Which means \u003Ccode>Promise.all\u003C\u002Fcode> can resolve cleanly while all three panels are broken, and the only symptom is \u003Ccode>undefined\u003C\u002Fcode> where the numbers should be. It gets better: calling \u003Ccode>res.json()\u003C\u002Fcode> on an HTML error page throws a \u003Ccode>SyntaxError\u003C\u002Fcode>, and that message sends you hunting for a parsing bug that does not exist. Check the status before you parse.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">async function json(url) {\n  const res = await fetch(url)\n  if (!res.ok) throw new Error(`${url} returned ${res.status}`)\n  return res.json()\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>The shape I ship now\u003C\u002Fh2>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">async function attempt(fn) {\n  try {\n    return { ok: true, data: await fn() }\n  } catch (error) {\n    return { ok: false, error }\n  }\n}\n\nconst [user, orders, metrics] = await Promise.all([\n  attempt(() =&gt; json('\u002Fapi\u002Fuser')),\n  attempt(() =&gt; json('\u002Fapi\u002Forders')),\n  attempt(() =&gt; json('\u002Fapi\u002Fmetrics')),\n])\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F38375326\u002Fpexels-photo-38375326.jpeg?auto=compress&cs=tinysrgb&w=1200\" alt=\"A sharp analytics dashboard on one screen with a second dashboard blurred behind it\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>Promise.all\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>A couple of things came out of this. The \u003Ccode>attempt\u003C\u002Fcode> helper went into \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> so I stop retyping it, and if your panels are slow rather than broken, the \u003Ca href=\"\u002Fposts\u002Freact-server-components-dashboard-performance\u002F\">dashboard performance post\u003C\u002Fa> is the next read. Cancellation is a separate problem with \u003Ca href=\"\u002Fposts\u002Fcanceling-fetch-abortcontroller\u002F\">AbortController\u003C\u002Fa>.\u003C\u002Fp>\n","2026-10-05",1791168103935]