skip to content

In a React client app, `<Dashboard>` fetches the current user in a `useEffect` and renders `<Projects>` only once that user has arrived; `<Projects>` then fetches in its own `useEffect`. Why do the two requests run one after the other instead of in parallel, and how would you remove that waterfall?

level: middleimportance: must knowfreq 62%

answer

  1. the child is not mounted yet
  2. sum of round trips, not the max
  3. the gate is the render, not the effect
  4. bundle and hydration come first
  5. hoist the requests or fetch above the tree

basics

~20 s

The child is not mounted until the parent's data arrives, so its effect is never scheduled and its request cannot start. Each nesting level adds a full round trip. Removing it means starting both requests from one place instead of gating one render on the other.

solid answer

~50 s

They are sequential because the child does not exist yet. The parent renders a spinner while its own request is in flight, so `<Projects>` is never mounted, its effect is never scheduled, and its request cannot begin until the parent's response arrives, sets state, re-renders and commits the child. Every extra level of nesting adds another full round trip, and the whole chain only starts after the bundle has downloaded and hydrated. To remove it you have to move the requests somewhere that knows about both up front: fire both from the parent and pass the results down, or fetch above the component tree entirely — a route loader or a server component that awaits both during render. Memoizing, reordering hooks, or switching to `useLayoutEffect` changes nothing, because the cause is the render gate, not effect timing.

code

jsx · 14 lines
jsx
import { useEffect, useState } from 'react';

// Waterfall: Projects is not mounted until /api/me resolves,
// so its own effect cannot start its request until then.
export function Dashboard() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/me').then((r) => r.json()).then(setUser);
  }, []);

  if (!user) return <p>Loading</p>;
  return <Projects userId={user.id} />;
}

go deeper

for a junior

Be able to say that a component that has not been rendered has no effect running, so a child that appears only after the parent's data cannot start its request earlier. Naming the pattern "request waterfall" is expected.

for a middle

Walk the sequence out loud — render, commit, effect, response, re-render, mount child, child effect — and explain why the total is the sum of the round trips. Show that hoisting both fetches into one effect removes an accidental chain.

for a senior

Demonstrate that you can spot it in a DevTools waterfall and separate accidental sequencing from a real data dependency. Talk about the bundle-and-hydrate prefix, lazily loaded children compounding the chain, and moving the fetch to a loader or the server.

for a principal

Own the API shape, not just the component: recurring waterfalls are usually a symptom of endpoints that force clients to walk a graph. Be ready to weigh a per-screen aggregate endpoint or server-side composition against the coupling it introduces.

## What a request waterfall is A waterfall is a chain of network requests where each one cannot start until a previous one has finished. The page's time to useful content becomes the *sum* of the round trips rather than the slowest of them. Two nested fetches on a 200 ms link cost 400 ms before anything renders; three cost 600 ms. On a mobile connection with 300–500 ms latency this is the difference between a page that feels instant and one that feels broken. ## Why effect-based fetching produces them structurally An effect belongs to a mounted component. React only mounts a component that an ancestor actually rendered. So the moment a parent writes `if (!user) return <Spinner />`, it has made its child's existence conditional on its own data. The child's `useEffect` is not delayed — it is not scheduled at all, because there is no child. The full sequence is: 1. `Dashboard` renders with `user === null`, commits, paints a spinner. 2. Its effect runs and starts `GET /api/me`. 3. The response arrives; `setUser` schedules a render. 4. That render finally returns `<Projects />`; React commits it. 5. Only now is the child's effect scheduled, and `GET /api/projects` leaves the browser. Nothing here is a bug in the code. It is what the model implies. This is why the problem is described as *structural*: you cannot fix it by writing a better effect, only by changing where the fetch lives. ## The waterfall in front of the waterfall A purely client-fetched page has a hidden first step. The browser must receive the HTML, download and parse the JavaScript bundle, and render before any effect can run. Code splitting can add another: if `<Projects>` is a lazily loaded chunk, its *chunk* download is itself gated on the parent's data, so the user pays a request round trip for code on top of the one for data. The data waterfall therefore sits at the end of a document → bundle → hydrate chain, and adding levels of component nesting extends it further. ## Removing it inside React The in-React fix is to stop conditioning the child's existence on the parent's data — hoist the requests so they start together: ```jsx useEffect(() => { fetch('/api/me').then((r) => r.json()).then(setUser); fetch('/api/projects/mine').then((r) => r.json()).then(setProjects); }, []); ``` This works when the second request does not genuinely need the first response. If it truly needs `user.id`, the dependency is real and no client-side rearrangement removes it — you need an endpoint that does not require the id (a `/mine` route), or a server that already knows the identity, or a single endpoint that returns both. A second in-React option is to render the child immediately and let it show its own placeholder rather than blocking on the parent, so both requests are in flight at once and each part of the UI fills in when it can. ## Removing it above React The alternatives listed in the leaf all attack the same gate: - **A route-level loader** starts every request a route needs as soon as navigation begins, in parallel, before any component renders. The component receives resolved data instead of triggering the fetch. - **A server component** runs on the server, close to the data, and `await`s during render — so nested awaits cost server-side hops (often sub-millisecond to a database in the same region) instead of client round trips, and the browser receives markup that already contains the content. - **A caching data layer** lets an ancestor or a route transition warm the cache ahead of time, so the child's read is a cache hit rather than a new request. ## What does not fix it Be ready to reject the plausible-sounding non-fixes. `React.memo`, `useMemo` and `useCallback` are about render cost, not request timing. `useLayoutEffect` runs earlier within the commit, but the child still is not mounted, so nothing starts sooner. Moving the child's fetch into the parent's dependency array is meaningless. And the browser is not the bottleneck: modern connections multiplex, so parallel requests really do overlap — the serialisation is entirely of your own making. In DevTools the signature is a staircase in the network waterfall, where each request begins about where the previous one ended, instead of bars that overlap.

  • How would you tell an accidental waterfall from a genuine data dependency?
    Ask whether the second URL actually needs a value from the first response. If it does — `/users/{id}/orders` built from the fetched id — the dependency is real and only a different endpoint or a server-side fetch removes the round trip. If the second request could have been issued with what the page already knew (a route param, a session cookie the server can read), the sequencing is accidental and hoisting the requests fixes it.
  • Does code splitting the child component make the waterfall better or worse?
    Worse, if the split boundary sits under the data gate. The lazily loaded chunk is only requested once the child is rendered, which is once the parent's data has arrived — so you pay a round trip for code *after* a round trip for data, and only then start the child's own request. Preloading the chunk alongside the parent's request, or splitting at a boundary that is not data-gated, keeps the code fetch off the critical chain.
  • Why does a server component's nested await not have the same cost as a nested client fetch?
    Because the hops are between the server and its data sources rather than between a browser and a server. A sequential pair of awaits inside a server render typically costs a few milliseconds against a database in the same region, versus hundreds of milliseconds per client round trip on mobile. The chain also starts immediately on the request, not after the bundle has downloaded and hydrated.

saying these in an interview costs you the question

  • Blames the browser's per-origin connection limit
  • Says React batches or serialises network requests
  • Suggests React.memo or useMemo to fix request timing
  • Claims useLayoutEffect starts the child's fetch sooner
  • Treats every nested fetch as a genuine data dependency

context