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 same four requests, sequential and parallel Top: four request bars each starting where the previous one ended, totalling 620 milliseconds. Bottom: the same four bars all starting at zero, finishing at 180 milliseconds, the duration of the slowest single request. Same requests, same server, 3.4× the wait waterfall — 620ms user 140ms workspace 150ms boards 120ms members 180ms — finishes at 620ms parallel — 180ms user workspace boards members — everything done at 180ms t=0
Read start times, not durations. Every bar in the top track begins where the one above it ends — that is the whole diagnosis.

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.

Where the four requests on a board page actually start Timeline showing the route match at zero milliseconds, prefetch of board and members at two milliseconds, component mount at 40, both queries resolving from in-flight requests at 180, and the page interactive at 190. Where the four requests on a board page actually start 0ms Route matched params known before any render 2ms Prefetch dispatched board and members in parallel 40ms Component mounts observers attach to in-flight requests 180ms Both resolve slowest request, not the sum 190ms Interactive no second wave of fetches
Starting at the route match rather than at mount removes the 40 milliseconds of bundle evaluation and render from the critical path.

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.

Time to a fully populated board page Bar chart of total time. A component waterfall takes 620 milliseconds. Hoisting the queries takes 180. Adding route-level prefetch takes 142. A warm cache takes 12. Time to a fully populated board page Four requests, 120 to 180ms each, throttled to Fast 3G component waterfall 620 ms hoisted, parallel 180 ms + route prefetch 142 ms warm cache 12 ms
Hoisting removes the waterfall; prefetching removes the render delay before it. The remaining 142ms is one network round trip.

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.