Crux Read real fetching snippets — a server waterfall, a queryKey, an RSC Suspense layout, and a search race — and pick the behaviour or the highest-leverage fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
Data-fetching bugs are read in code and in the Network panel. Read each snippet, predict the round-trip shape, and choose the fix a senior frontend engineer makes first.
Goal
Practise the loop you run in every fetching review: trace which requests are sequential vs parallel, read a cache key for its identity, spot an RSC waterfall hiding in a loop, and kill a response race at the source.
Snippet 1 — the server component loop
// Server Component — renders a 50-item order listasync function OrderList({ ids }: { ids: string[] }) { const orders = []; for (const id of ids) { orders.push(await fetchOrder(id)); // one await per iteration } return <ul>{orders.map(o => <Order key={o.id} data={o} />)}</ul>;}
Quiz
Completed
Each fetchOrder takes ~40ms. What is the total fetch time, and what is the fix?
Heads-up Only sibling Server Components fetch in parallel. An explicit await inside a loop runs strictly sequentially; the parallelism has to be expressed with Promise.all.
Heads-up JavaScript never reorders or parallelises awaits in a loop. Each await pauses the function until that promise settles before the next iteration starts.
Heads-up Promise.all preserves input order in its result array, so ordering is free. The sequential cost is avoidable; the for-await loop is the defect.
Snippet 2 — the query key
function useProduct(id: string, currency: string) { return useQuery({ queryKey: ['product', id], // currency is NOT in the key queryFn: () => fetchProduct(id, currency), });}
Quiz
Completed
A user switches currency from USD to EUR on the same product. What goes wrong, and why?
Heads-up queryFn does close over currency, but the cache is keyed only by ['product', id]. If that entry is fresh, TanStack Query returns the cached value without calling queryFn at all — the new currency never fetches.
Heads-up The key did not change — currency is not in it — so nothing invalidates. The bug is the opposite: a stale cache hit, not an extra fetch.
Heads-up staleTime=0 forces refetch-on-mount but still returns the stale cached USD value first, and conflates two currencies in one entry. Identity belongs in the key; every input that changes the response must be part of queryKey.
Reviews takes 800ms. When does the user first see the product title, and why?
Heads-up That is blocking SSR without Suspense. Here the Suspense boundary around Reviews lets the server stream the title immediately and send the reviews chunk later.
Heads-up Suspense isolates only its subtree behind the fallback. The h1 is outside the boundary and streams with the shell; only Reviews waits behind the skeleton.
Heads-up The title is server-rendered HTML and is visible before any JS runs. Hydration attaches interactivity afterward; it is not required for the title to paint.
Snippet 4 — the search box
function useSearch(query: string) { const [results, setResults] = useState([]); useEffect(() => { fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(setResults); // no cleanup, no cancellation }, [query]); return results;}
Quiz
Completed
Under fast typing this intermittently shows results for an earlier query. What is the minimal correct fix?
Heads-up A delay does not order responses — the stale 'reac' response can still land last. Only cancellation (or queryKey versioning) guarantees the latest query wins.
Heads-up useMemo avoids recomputing a value; it does nothing about out-of-order network responses. The race is in fetch timing, not in recomputation.
Heads-up Fetching during render is a worse anti-pattern — it fires on every render and still has no cancellation. The fix is an AbortController in the effect cleanup, or a query library that cancels stale keys.
Recap
Every fetching bug is visible in the code: an await inside a loop is a server-side waterfall — Promise.all bounds it to the slowest call; a queryKey must contain every input that changes the response or the cache serves the wrong entry; a Suspense boundary lets the shell stream at TTFB while slow children resolve independently; and an uncancelled effect fetch races on fast input — an AbortController in the cleanup, or a library that cancels stale keys, makes the latest query win. Read the code, trace the round-trips, fix the structure before reaching for a knob.