Preventing Request Waterfalls With Parallel Queries
A screen with four 150-millisecond requests can take 150 milliseconds or 600, and nothing in the code makes the difference obvious — the queries look identical either way. What decides it is when each one starts, which is a function of component structure rather than of data. This page covers finding and flattening those chains, under Stale-While-Revalidate Implementation, because a waterfall is the fastest way to make a well-tuned cache feel slow. It complements Showing Stale Data While Revalidating in the UI — that page shortens the perceived wait, this one shortens the actual one — and it overlaps with Prefetching Queries on Hover and Route Change at the route boundary.
Diagnostic Checklist
Prerequisites
- The network panel, or the lifecycle log from Logging Query Lifecycle Events for Debugging.
- A route that renders several queries.
- Throttling enabled — waterfalls are invisible on a fast connection.
Step 1 — Identify the Waterfall From Start Times
A waterfall is a statement about scheduling. Sorting requests by start time and comparing against the previous end time identifies it mechanically.
export function findWaterfalls(entries: PerformanceResourceTiming[], toleranceMs = 30) {
const api = entries
.filter((entry) => entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest')
.sort((a, b) => a.startTime - b.startTime);
const chains: Array<{ after: string; before: string; gapMs: number }> = [];
for (let i = 1; i < api.length; i++) {
const previousEnd = api[i - 1].responseEnd;
const gap = api[i].startTime - previousEnd;
// Started within a few ms of the previous one FINISHING: almost certainly
// waiting on it, rather than a coincidence.
if (Math.abs(gap) <= toleranceMs) {
chains.push({ after: api[i - 1].name, before: api[i].name, gapMs: Math.round(gap) });
}
}
return chains;
}
Cache Behavior Analysis: PerformanceResourceTiming reports startTime at request initiation and responseEnd at completion, so a near-zero gap between one request’s end and the next’s start is strong evidence of a dependency rather than coincidence — genuinely independent requests started by the same render begin within a millisecond of each other. Requests deduplicated by the cache do not appear at all, which is helpful: the timing API sees network activity, so a waterfall of cache reads is invisible here and must be found in the lifecycle log instead. Run this with throttling on, because on a fast local connection every gap collapses below the tolerance and everything looks parallel.
Step 2 — Hoist Independent Queries Above Their Consumers
The most common waterfall is structural: a child fetches something the parent could have fetched, so the child’s request cannot start until the parent has rendered.
// ✗ BoardMembers cannot start until BoardHeader has data and rendered.
function BoardPageBad({ boardId }: { boardId: string }) {
const { data: board } = useQuery(boardQueries.detail(boardId));
if (!board) return <Skeleton />;
return (
<>
<BoardHeader board={board} />
<BoardMembers boardId={board.id} /> {/* fetches only after board resolves */}
</>
);
}
// ✓ Both queries are declared in the same render pass, so both start at once.
function BoardPageGood({ boardId }: { boardId: string }) {
const boardQuery = useQuery(boardQueries.detail(boardId));
// boardId comes from the route — the members query never needed the board.
const membersQuery = useQuery(boardQueries.members(boardId));
if (boardQuery.status === 'pending') return <Skeleton />;
return (
<>
<BoardHeader board={boardQuery.data} />
<BoardMembers members={membersQuery.data} loading={membersQuery.status === 'pending'} />
</>
);
}
Cache Behavior Analysis: Both useQuery calls in the second version execute during the same render, so both observers attach and both fetches start in the same tick — the browser sends them concurrently and total time becomes the slower of the two rather than their sum. The early return <Skeleton /> in the first version is what creates the waterfall: it prevents BoardMembers from ever mounting, so its query is not merely late, it does not exist yet. Note that the dependency was never on the data at all; boardId came from the route, and only the code’s shape made the members query appear to need the board.
Step 3 — Fan Out Variable-Length Sets With useQueries
When the number of queries depends on data, you cannot write a fixed number of useQuery calls. useQueries takes an array and starts all of them together.
import { useQueries } from '@tanstack/react-query';
export function BoardColumns({ columnIds }: { columnIds: string[] }) {
const results = useQueries({
queries: columnIds.map((id) => ({
queryKey: ['columns', 'detail', id],
queryFn: () => fetchColumn(id),
staleTime: 30_000,
})),
// combine runs once per result change and produces one stable object,
// so the component re-renders once rather than once per resolved query.
combine: (queries) => ({
columns: queries.map((query) => query.data).filter(Boolean),
pending: queries.some((query) => query.status === 'pending'),
failed: queries.filter((query) => query.status === 'error').length,
}),
});
if (results.pending && results.columns.length === 0) return <ColumnsSkeleton />;
return <ColumnGrid columns={results.columns} partialFailures={results.failed} />;
}
Cache Behavior Analysis: useQueries creates one observer per entry and attaches them all in the same render, so every request leaves together — subject to the browser’s per-origin connection limit, which on HTTP/1.1 is six and means a twenty-item fan-out runs in four waves. The combine option is what keeps the fan-out from being a rendering problem: without it, twenty queries resolving at different times produce twenty renders, and with it the component re-renders on changes to the combined value only. Each entry is a normal cache entry, so a column already fetched by another screen resolves instantly and never reaches the network at all.
Step 4 — Start the First Request at the Route Boundary
Even a perfectly parallel screen waits for its own JavaScript. Starting the fetch when the route matches removes bundle evaluation and render from the critical path.
// TanStack Router / React Router data APIs: the loader runs on navigation,
// before the route's component has been imported or rendered.
export const boardRoute = {
path: '/boards/$boardId',
loader: async ({ params, context }) => {
const client = context.queryClient;
// Fire both, await neither: the component's useQuery calls will attach
// to these in-flight requests instead of starting new ones.
void client.prefetchQuery(boardQueries.detail(params.boardId));
void client.prefetchQuery(boardQueries.members(params.boardId));
return null;
},
component: BoardPage,
};
Cache Behavior Analysis: prefetchQuery writes into the same cache entry that useQuery will read, so when the component mounts its observer attaches to a request already in flight rather than starting a second one — the deduplication is what makes this safe to fire without awaiting. Returning without awaiting means navigation is not blocked on the network: the route renders its skeleton immediately and the data arrives into an entry that is already being filled. prefetchQuery also respects staleTime, so navigating back to a recently visited board issues no request at all, which is the same mechanism described in Warming the Cache During App Bootstrap.
Edge Cases & Gotchas
Genuine dependencies still exist. When query B truly needs an id that only A returns, enabled: !!a.data is correct and the waterfall is real. The fix there is on the server: return the id in A’s response, or add an endpoint that answers both.
Suspense boundaries serialize by default. Two useSuspenseQuery calls in the same component do run in parallel, but two components each suspending inside their own boundary do not — the second does not mount until the first resolves. Hoist to useSuspenseQueries when both belong to the same screen.
Connection limits cap the fan-out. On HTTP/1.1 the browser allows six concurrent connections per origin, so a hundred-query fan-out is not a hundred parallel requests. On HTTP/2 the limit is far higher but server concurrency becomes the constraint instead — measure rather than assume.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Requests start in a staircase | A child’s query cannot mount until a parent’s resolves | Hoist both queries into the same render pass |
| A list’s rows appear one at a time | Each row mounts its own query after the previous row rendered | Fan out with useQueries at the list level |
| Adding suspense made the page slower | Two boundaries serialized two independent queries | Combine into useSuspenseQueries in one component |
| The fan-out is still slow with 40 items | The browser’s per-origin connection limit is throttling it | Batch server-side, or paginate the fan-out |
| Prefetch has no effect | The route loader awaited the prefetch, blocking navigation | Fire without awaiting and let the observer attach |
Frequently Asked Questions
How do I tell a waterfall from slow requests?
Compare start times rather than durations. Requests that begin within a few milliseconds of each other are parallel however long they take, and the screen’s total time is the slowest one; requests whose start time coincides with a previous request’s end time are chained, and their durations add. The arithmetic makes it unambiguous: if four 150-millisecond requests produce a 600-millisecond screen, nothing about the server is slow — the requests are simply queued behind each other by your component structure.
Are dependent queries always a waterfall?
Technically yes, but most dependencies turn out to be accidental rather than real. Before accepting one, ask what the second query actually needs: very often it is a single id that the route already has in its params, or that the first endpoint could include in a field it does not currently return. A genuine dependency — where the second request’s shape is unknowable until the first resolves — is unavoidable on the client and should be fixed on the server, either by returning the id or by providing an endpoint that answers both questions at once.
Does useQueries run everything in parallel?
It starts everything in the array during the same render, which is as parallel as the client can make it — but the browser then applies its own limits. On HTTP/1.1 that is six concurrent connections per origin, so a twenty-query fan-out runs as four waves and a hundred-query fan-out is a queue whose last requests wait a very long time. On HTTP/2 the connection limit largely disappears and the constraint moves to your server’s concurrency. Either way, a fan-out above roughly a dozen items is usually a sign that the endpoint should accept a list of ids instead.
Related
- Stale-While-Revalidate Implementation — the parent guide on revalidation strategy and cached reads.
- Showing Stale Data While Revalidating in the UI — shortening the perceived wait once the actual one is minimal.
- Warming the Cache During App Bootstrap — starting the first requests before any route has matched.
- Prefetching Queries on Hover and Route Change — the route-boundary technique in more depth.