Hidden Request Waterfalls: Matching Async Code to the Real Dependency Graph
Learn to spot sequential awaits that invent fake dependencies, fix them with Promise.all, and restructure a React dashboard so each component fetches only what it needs.
This article is published in English.
Sources: Promises Make Async Code Look More Sequential Than It Really Is
async/await makes asynchronous code read top to bottom, and that readability has a cost: every await quietly declares that the next line depends on the previous one. When that declaration is false, you get a request waterfall that looks perfectly clean in review and only shows up as latency in the network tab. Below, a dashboard loader with that problem is fixed first with Promise.all, then by splitting the component so the correct order emerges on its own.
A dashboard loader that does everything
Dashboard editors look simple, yet a single component often ends up loading the dashboard, fetching every widget, loading each widget's options and filters, resolving the widget type, fetching its data and finally rendering the UI. Here is a simplified version of the data-loading part of such a component:
useEffect(() => {
const loadDashboard = async () => {
const dashboard = await getDashboard(id);
const widgets = [];
for (const widgetId of dashboard.widgetIds) {
const options = await getWidgetOptions(widgetId);
const filters = await getWidgetFilters(widgetId);
const widget = await getWidget(widgetId);
const data = await getWidgetData(
widgetId,
filters,
options,
);
widgets.push({
widget,
options,
filters,
data,
});
}
setWidgets(widgets);
};
loadDashboard();
}, [id]);
Beyond being imperative code inside an effect, it bakes in a wrong assumption.
Two separate problems in one loop
The first issue is easy to see: the for...of loop handles widgets one at a time, even though widgets are independent. Promise.all is the obvious answer, but it only addresses half of the problem.
The second issue is subtler. Inside the loop, the code requests options and waits, then requests filters and waits, then requests the widget and waits, and only then requests the data. Those awaits describe dependencies that do not exist. Options do not need filters, filters do not need the widget, and the data request never uses the widget at all. The real dependency graph for a single widget looks like this:
widgetId → options ─┐
→ filters ─┴→ data
→ widget
What the code actually implements is a straight chain:
widgetId → options → filters → widget → data
For a dashboard with N widgets, the original loader makes roughly 4N round trips in sequence. The network tab shows it clearly: requests leave one after another, widget by widget.
First fix: encode the real graph with Promise.all
The quick fix is to make the code follow the true graph. The widgets are mapped in parallel, and within each widget the three independent requests start together. Only the data request waits, because it genuinely needs options and filters:
useEffect(() => {
const loadDashboard = async () => {
const dashboard = await getDashboard(id);
const widgets = await Promise.all(
dashboard.widgetIds.map(async (widgetId) => {
const [options, filters, widget] = await Promise.all([
getWidgetOptions(widgetId),
getWidgetFilters(widgetId),
getWidget(widgetId),
]);
const data = await getWidgetData(
widgetId,
filters,
options,
);
return {
widget,
options,
filters,
data,
};
}),
);
setWidgets(widgets);
};
loadDashboard();
}, [id]);
The critical path drops from a chain of four requests per widget, multiplied by the widget count, to the dashboard request plus the slowest of the three parallel calls plus the data call.
Two things are worth adding before shipping this. Promise.all rejects as soon as any request fails, so one broken widget blanks the whole dashboard; Promise.allSettled or per-widget error handling keeps the rest visible. And the effect has no cleanup, so if id changes quickly, a slow response for the old dashboard can overwrite the new one. An ignore flag or an AbortController in the cleanup function prevents that.
Better fix: let components own their requirements
Even after the fix, one component still owns every request and every model the dashboard needs. A more durable change is to split the UI by responsibility and let each piece fetch its own inputs through data hooks:
const DashboardsPage = () => {
const dashboards = useDashboards();
return <Navigate to={`/dashboards/${dashboards[0].id}`} />;
};
const Dashboard = () => {
const { id } = useParams();
const dashboard = useDashboard(id);
return dashboard.widgetIds.map((widgetId) => (
<WidgetSkeleton key={widgetId} widgetId={widgetId} />
));
};
const WidgetSkeleton = ({ widgetId }: { widgetId: string }) => {
const options = useWidgetOptions(widgetId);
const filters = useWidgetFilters(widgetId);
return (
<>
<WidgetHeader options={options} filters={filters} />
<Widget
widgetId={widgetId}
options={options}
filters={filters}
/>
</>
);
};
DashboardsPage redirects to the first dashboard (guard against an empty list in real code). Dashboard only knows the widget ids and renders a keyed WidgetSkeleton for each. WidgetSkeleton needs options and filters for the header and the widget, so those are simply two requirements of the same piece of UI, with no ordering between them.
The widget definition, which decides the visualization type, is the concern of the Widget component:
const Widget = ({
widgetId,
options,
filters,
}: {
widgetId: string;
options: WidgetOptions;
filters: WidgetFilters;
}) => {
const widget = useWidget(widgetId);
const Component =
MAP_WIDGET_TYPE_TO_VISUALIZATION[widget.type];
return (
<Component
widgetId={widgetId}
options={options}
filters={filters}
/>
);
};
It picks the visualization from MAP_WIDGET_TYPE_TO_VISUALIZATION, and the visualization requests its data from the three values it actually depends on:
const LineChart = ({
widgetId,
options,
filters,
}: LineChartProps) => {
const data = useWidgetData(widgetId, filters, options);
// ...
};
No promises were tuned here. Separating responsibilities made the dependency graph visible in the component tree: the dashboard must arrive before widget ids are known; each widget id unlocks options, filters and the widget definition; the data waits only for its real inputs.
A caveat about suspending hooks
Whether these requests really run in parallel depends on how the hooks are implemented. Hooks that start a request and return a loading state let every component render at once, so all requests fire together. If the hooks suspend (for example, suspense-mode queries that return data directly, which the snippets appear to assume since they read widget.type without a loading check), the picture changes. Two suspending hooks called one after another in the same component can run in series, and a child such as Widget does not render, and so does not start its own request, until its parent stops suspending. In that setup you can reintroduce a waterfall through the tree. Prefetching in the parent, a combined query API or a route loader avoids that. For a Suspense-specific treatment, see parallel fetching in React Suspense.
Key takeaways
- Every
awaitis a claim that the next step depends on the previous result; check that each claim is true. - Draw the dependency graph before optimizing, then make the code match it rather than the order you wrote the lines in.
Promise.allfixes independent requests, but pair it with error isolation and effect cleanup.- Splitting components by what they own tends to yield the right request order, unless suspending hooks serialize it.