One unreliable dashboard request should not automatically blank every independent
widget. If a page waits for projects, alerts, and activity through a single
Promise.all(), a failed alerts request rejects the combined promise—even when
the projects and activity requests have already succeeded.
When those panels are genuinely independent, use Promise.allSettled() at
their shared aggregation boundary, then give each panel its own ready or
error outcome. A successful empty array is still a successful result, not an
error. The example below reproduces the difference with local promises, no
credentials, and no API calls.
Verified version boundary (October 12, 2026): the current PyColors Marketing
manifest specifies Next.js 16.1.1, React and React DOM ^19.3.0,
TypeScript ^6.0.3, Node 24.x, and pnpm 10.32.1. Those are repository
declarations, not a newly installed public consumer. The standalone
dashboard-outcomes.ts test below was compiled with TypeScript 5.8.3 and
run with Node 22.16.0 in an isolated local directory. A complete
Marketing/React app build and a browser or screen-reader test were not run before
opening this draft PR. The example uses standard Promise functionality and
does not import a private PyColors library.
This is an application data-flow decision, not a PyColors component feature. The Empty, loading, and error states guide explains the visual role of each state; this article focuses on preventing an unrelated failure from hiding valid sibling results.
Reproduce the all-or-nothing result
Imagine three independently useful dashboard sources:
- Projects: a list of project names.
- Alerts: recent workspace notices.
- Activity: recent event descriptions.
The straightforward aggregation loses all three as a combined result when any one rejects:
async function loadDashboardBroken() {
const [projects, alerts, activity] = await Promise.all([
Promise.resolve(["Atlas"]),
Promise.reject(new Error("Alert service unavailable")),
Promise.resolve(["Project created"]),
]);
return { projects, alerts, activity };
}The projects and activity promises still settle successfully; Promise.all()
does not cancel them. However, the enclosing await rejects, so this
function never returns their values. A page that catches the single exception
and renders one global error discards usable information.
That is correct behavior when every result is required for one atomic
decision. It is the wrong behavior when the cards represent independent
information a reader could still use separately.
MDN's Promise.all() reference
documents its first-rejection behavior.
Keep an outcome for each panel
The code below is a complete, framework-independent TypeScript module. The three data sources are injected so the test can use local fixtures. In an application, each source would be implemented by your own trusted data layer.
export type Panel<T> =
{ status: "ready"; data: T } | { status: "error"; message: string };
export type DashboardSources = {
projects: () => Promise<readonly string[]>;
alerts: () => Promise<readonly string[]>;
activity: () => Promise<readonly string[]>;
};
function asPanel<T>(result: PromiseSettledResult<T>): Panel<T> {
if (result.status === "fulfilled") {
return { status: "ready", data: result.value };
}
// Do not echo provider exceptions into the public interface.
return { status: "error", message: "This panel could not load." };
}
export async function loadDashboard(sources: DashboardSources) {
const [projects, alerts, activity] = await Promise.allSettled([
sources.projects(),
sources.alerts(),
sources.activity(),
] as const);
return {
projects: asPanel(projects),
alerts: asPanel(alerts),
activity: asPanel(activity),
};
}The return value is now a typed result for each source. No any, swallowed
unknown exception message, or fabricated fallback value is needed.
Promise.allSettled() waits until every input has either fulfilled or
rejected, then returns results in input order, not completion order. It does
not retry, abort, cache, or deduplicate any request. Those decisions remain
separate. See
MDN's Promise.allSettled() reference.
This is a final aggregate result pattern: it waits for the slowest input
before returning. If a page needs each panel to stream or update independently
as soon as it settles, use separate async boundaries or the framework's
supported streaming primitives rather than claiming allSettled() makes
rendering progressive.
Test a failure without timing guesses
Save the module as dashboard-outcomes.ts, then add this independent Node test
file. The test uses only built-in node:test and node:assert; none of the
example sources call a live service.
import assert from "node:assert/strict";
import test from "node:test";
import { loadDashboard } from "./dist/dashboard-outcomes.js";
test("one failed source does not erase sibling results", async () => {
const panels = await loadDashboard({
projects: async () => ["Atlas"],
alerts: async () => {
throw new Error("PRIVATE_PROVIDER_DETAILS");
},
activity: async () => ["Project created"],
});
assert.deepEqual(panels.projects, {
status: "ready",
data: ["Atlas"],
});
assert.deepEqual(panels.alerts, {
status: "error",
message: "This panel could not load.",
});
assert.deepEqual(panels.activity, {
status: "ready",
data: ["Project created"],
});
assert.equal(
JSON.stringify(panels).includes("PRIVATE_PROVIDER_DETAILS"),
false,
);
});
test("empty is a valid ready result, not a failed request", async () => {
const panels = await loadDashboard({
projects: async () => [],
alerts: async () => [],
activity: async () => [],
});
assert.deepEqual(panels.projects, { status: "ready", data: [] });
assert.equal(panels.alerts.status, "ready");
});
test("each failed source remains an explicit failure", async () => {
const failure = async () => {
throw new Error("offline");
};
const panels = await loadDashboard({
projects: failure,
alerts: failure,
activity: failure,
});
assert.equal(panels.projects.status, "error");
assert.equal(panels.alerts.status, "error");
assert.equal(panels.activity.status, "error");
});One reproducible way to execute these files in an isolated directory is:
# Save dashboard-outcomes.ts and dashboard-outcomes.test.mjs beside package.json.
printf '{"type":"module","private":true}\n' > package.json
# Use a TypeScript compiler already installed in this directory/environment.
tsc --strict --target ES2022 --module ES2022 --moduleResolution node \
--outDir dist dashboard-outcomes.ts
node --test dashboard-outcomes.test.mjsThe tests verify the narrow outcome contract, not real backend behavior. Before integrating it into a Next.js application, run the same cases through your real data adapters and application build.
Rendering is a separate decision
A ready panel can still be empty. Do not infer failure from
data.length === 0, and do not substitute zero for missing metrics. For a
projects panel, the display boundary can follow a small, explicit order:
import type { Panel } from "./dashboard-outcomes";
export function ProjectsPanel({
result,
}: {
result: Panel<readonly string[]>;
}) {
if (result.status === "error") {
return (
<section aria-labelledby="projects-heading">
<h2 id="projects-heading">Projects</h2>
<p role="alert">{result.message}</p>
<a href="/dashboard">Reload the dashboard</a>
</section>
);
}
return (
<section aria-labelledby="projects-heading">
<h2 id="projects-heading">Projects</h2>
{result.data.length === 0 ? (
<p>No projects yet.</p>
) : (
<ul>
{result.data.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
)}
</section>
);
}This is a presentational integration sketch. It assumes /dashboard is your
application's own route and unique project names in the small fixture; use
stable record IDs in real lists. In a server-rendered page, a plain link reloads
or navigates to the dashboard rather than retrying just one source. If a widget
needs local retry without reloading the page, that interaction and its loading
state belong in a separate client-side boundary or data-fetching abstraction.
A request still in progress needs a loading state; this aggregation function only produces outcomes after all three promises settle. Use a surrounding route-level loading state or separate panel boundaries while work is pending. Do not show “No projects yet” before a successful response says the list is empty.
The existing
PyColors async-states guide explains where
Skeleton, EmptyState, and Alert fit, including keyboard and messaging
considerations. This example does not claim to test actual screen-reader speech
or certify accessibility.
When not to use this pattern
Promise.allSettled() is useful for independent read-only presentation
data. It is not an idempotency mechanism, an authorization check, an
orchestration queue, or a transaction boundary. Do not apply this partial-success
model to a payment/entitlement flow in which multiple writes must either agree
or be reconciled durably. A successful project list does not justify pretending
a failed billing summary is healthy.
It also does not protect against an outdated request overwriting newer UI state during repeated navigation or input changes. That separate race is covered by How to stop stale async validation from overwriting React form state.
Finally, this example does not define retry budgets, backoff, caching, deadlines, cancellation, or error observability. Add those based on your application's actual behavior and approved data boundaries. Log meaningful internal failures through your existing safe diagnostics; display neutral user-facing copy rather than raw service errors.
Verification checklist
Before using partial dashboard results in a SaaS application:
- Confirm every source really is independently useful; do not split one financial or authorization decision into fake partial success.
- Test all sources ready, one rejected, and all rejected.
- Confirm a successful empty list remains
ready, distinct from a failure. - Keep the return types bound to the correct panel when promises complete out of order.
- Confirm unknown service errors are not embedded in rendered content or telemetry.
- Decide whether waiting for the slowest request is acceptable; use separate boundaries when it is not.
- Give each failed panel a meaningful recovery action without implying a retry already happened.
- Verify loading treatment independently from the final
ready/errorresult. - Test mobile layouts, keyboard navigation, focus, contrast, and empty/error copy in the real consuming app.
- Treat server-side data validation, authorization, and consistency as application responsibilities.
Sources and next steps
Primary platform references:
MDN Promise.all()
and
MDN Promise.allSettled().
Product references: PyColors async-state guide, Data Table Block, and Stats Overview Block.
The Stats Overview Block renders a responsive
metric summary from values your application supplies. It does not fetch data,
recover failures, or assemble Promise.allSettled() results. Use it as a
presentation layer only after deciding which data is actually available.
Use the Stats Overview Block when you want a copyable presentation layer for the metrics that did load. Your application remains responsible for data loading, state ownership, permissions, and recovery.