open atlas
↑ Back to track
React patterns, senior RXP · 13 · 03

Avoiding request waterfalls

A request waterfall is independent data fetched serially — invisible until real latency hits; fix it by running independent requests in parallel and prefetching likely-next data, without serializing requests that genuinely depend on each other.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

On your machine every request returns in 2ms, so a page that fires four of them one after another feels instant. You ship it. Then a user on hotel Wi-Fi opens the same page and each round trip costs 300ms — and because the requests run one-at-a-time, the page that took 8ms locally now takes over a second. The data never depended on each other; the code just never said so.

This is a request waterfall: independent data fetched serially because each await, or each nested component, blocks the one after it. It’s the most common performance bug in data-heavy React because it’s invisible exactly where you test it — under zero latency — and only appears under the latency your users actually have. This lesson is about seeing waterfalls and flattening them, and about the two ways flattening goes wrong.

Goal

After this lesson you can spot a request waterfall in both client code (sequential awaits, fetch-in-effect chains, nested Suspense data components) and RSC trees; flatten independent requests by hoisting them and running them concurrently with Promise.all or parallel RSC fetches; prefetch likely-next data to hide latency before it’s noticed; and — the senior half — recognize the two failure modes: parallelizing requests that genuinely depend on each other (which breaks correctness), and over-prefetching everything (which wastes bandwidth and server work).

1

A waterfall is serial latency on data that has no serial dependency. The tell is two awaits back to back where the second argument doesn’t use the first result. Each await parks the function until its promise settles, so the requests run nose-to-tail and the total time is the sum of the round trips instead of the max.

// waterfall: user and posts are independent, but run serially
async function load(userId: string) {
  const user = await fetchUser(userId);    // 300ms
  const posts = await fetchPosts(userId);  // 300ms — starts only after user resolves
  return { user, posts };                  // total ≈ 600ms
}

fetchPosts never reads user. There is no reason for it to wait. The fix is to start both before awaiting either, so the round trips overlap.

2

Fix independent requests by kicking them off together, then awaiting the group — Promise.all. Start the promises first (don’t await yet), then await them as a set. Now the two round trips overlap and total time collapses from sum to max.

// parallel: both requests are in flight before either is awaited
async function load(userId: string) {
  const userP = fetchUser(userId);    // started, not awaited
  const postsP = fetchPosts(userId);  // started, not awaited
  const [user, posts] = await Promise.all([userP, postsP]); // ≈ 300ms total
  return { user, posts };
}

The structural shift is small but exact: create the promises eagerly, await them late. If one rejects, Promise.all rejects immediately — use Promise.allSettled when a partial result should still render. This is the same move whether you’re in a route loader, a server action, or a plain async function.

3

In RSC, the waterfall hides in the component tree: a child that awaits can’t start until its parent has rendered. Server Components can be async and await data directly, which is clean — but if a parent awaits, renders, and only then renders a child that awaits, the child’s request starts late. Hoist independent fetches to a common ancestor and start them in parallel, or render sibling async components so React can work them concurrently.

// waterfall: Profile awaits, THEN renders Feed which awaits
async function Profile({ id }: { id: string }) {
  const user = await getUser(id);            // round trip 1
  return <><Header user={user} /><Feed id={id} /></>; // Feed's fetch starts only now
}

// flattened: start both up front, hand promises down, let Suspense stream each in
function Profile({ id }: { id: string }) {
  const userP = getUser(id);                 // both in flight immediately
  const feedP = getFeed(id);
  return (
    <>
      <Suspense fallback={<HeaderSkeleton />}><Header userP={userP} /></Suspense>
      <Suspense fallback={<FeedSkeleton />}><Feed feedP={feedP} /></Suspense>
    </>
  );
}

Passing the promise down (and reading it with use inside each child) lets both requests start at the same instant while each section streams in independently behind its own Suspense boundary.

4

Prefetch likely-next data so the round trip is already done when the user asks. If you can predict the next view — a row the user is hovering, the route their cursor is on, the tab they’ll open — start its request early instead of waiting for the click. With a server-state cache (TanStack Query, RSC route prefetch) the fetched data is warm by the time navigation happens, turning a visible spinner into an instant render.

// warm the cache on hover; the click then reads from cache, no waterfall
function UserRow({ id }: { id: string }) {
  const qc = useQueryClient();
  return (
    <a
      href={`/users/${id}`}
      onMouseEnter={() => qc.prefetchQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) })}
    >
      View
    </a>
  );
}

Prefetch is latency hidden ahead of time rather than shrunk. But it spends bandwidth and server work on a guess — so prefetch only what’s likely next (the hovered row, the adjacent route), never everything on the page. Over-prefetching is its own performance bug, covered below.

Worked example

A dashboard panel, waterfall to parallel. The panel shows the current user, their team, and the team’s recent activity. The first version reads cleanly top to bottom — and is three serial round trips.

// BEFORE: three awaits, each independent of the previous, ~900ms on a 300ms RTT
async function DashboardData({ userId }: { userId: string }) {
  const user = await fetchUser(userId);        // 300ms
  const team = await fetchTeam(userId);        // +300ms (doesn't use `user`)
  const activity = await fetchActivity(userId); // +300ms (doesn't use `user` or `team`)
  return { user, team, activity };
}

None of the three requests consumes another’s result — they all key off userId, which we already have. So they have no business running in sequence. Start all three, then await the group:

// AFTER: all three in flight at once, ~300ms total
async function DashboardData({ userId }: { userId: string }) {
  const [user, team, activity] = await Promise.all([
    fetchUser(userId),
    fetchTeam(userId),
    fetchActivity(userId),
  ]);
  return { user, team, activity };
}

Three round trips became one round trip’s worth of wall-clock time — a 3× speedup with zero change to what’s fetched or rendered. Now suppose activity genuinely needed the team id (fetchActivity(team.id)): that one dependency is real, so it must await team. The senior move is to parallelize what’s independent and only serialize the true dependency: const team = await fetchTeam(userId) in parallel with user, then fetchActivity(team.id) after. You don’t flatten blindly — you flatten exactly the edges that aren’t real dependencies.

Common mistake

The dangerous over-correction is parallelizing requests that genuinely depend on each other. If fetchActivity needs an id that only fetchTeam returns, you cannot fire them together — Promise.all([fetchTeam(userId), fetchActivity(???)]) has no id to pass. Forcing parallelism here means either guessing the id, fetching the wrong thing, or fetching too much and filtering client-side. A true data dependency is a correctness constraint, not a latency bug. The skill is telling the two apart: independent requests (same input, no result-passing) parallelize; dependent requests (one’s input is another’s output) stay sequential. Flattening a real dependency doesn’t make the page faster — it makes it wrong.

Edge cases

The other failure mode is prefetching everything “to be fast”. Prefetch spends real bandwidth and real server work on a prediction, and most predictions are wrong: prefetching every row of a 100-item list on mount, or every route the page links to, can fire dozens of requests the user never needed — slowing the requests they do need by contending for the connection pool, and adding load to your backend for nothing. Prefetch is a bet; bet only on high-probability next steps (the hovered link, the most-likely tab, the next page of a paginated list the user is scrolling). On metered or slow connections, gate prefetch behind navigator.connection?.saveData or skip it entirely. “Fetch in parallel” and “prefetch aggressively” are not the same instruction — the first is free, the second has a cost you must justify.

Check yourself
Quiz

A loader does `const order = await fetchOrder(orderId)` then `const customer = await fetchCustomer(order.customerId)`. A teammate wants to wrap both in Promise.all to 'kill the waterfall'. What's the correct call?

Recap

A request waterfall is independent data fetched serially — back-to-back awaits where the later request doesn’t use the earlier result, or an RSC child whose fetch can’t start until its parent has rendered. It’s invisible under the zero latency you test with and brutal under the latency your users have, where total time becomes the sum of round trips instead of the max. Flatten it by hoisting the independent requests and running them concurrently — Promise.all (or allSettled for partial results) in client and loader code, parallel async siblings with promises passed down to Suspense boundaries in RSC. Hide remaining latency by prefetching likely-next data so the round trip finishes before the user asks. Then guard the two failure modes: never parallelize a genuine dependency (one request’s input is another’s output — that must stay sequential, or the page is wrong), and never over-prefetch (a guess that costs bandwidth and server work, justified only for high-probability next steps). Parallelize what’s independent; serialize what truly depends; prefetch what’s likely — and nothing more.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.