Server data fetching without waterfalls
An await holds only what renders below it: start independent reads together, share repeats with cache(), and stream the slow part.
Predict first
Before you read: predict
ExamHall's attempt page has a layout, the page, and a <Timer> inside the page. Each prints the signed-in candidate's name, and each calls getCandidate(), defined once in lib/session.js as cache(async () => db.candidates.find(sid)), where sid comes from the session cookie. The page renders on every request. Ana opens the page, then Ben opens it.
How many queries does the candidates table receive in total?
The 30-second version
In a Server Component each await holds whatever that component renders, so reads awaited in turn, or placed in a child that renders only after its parent's read, start late. Start independent reads together. React's cache() lets repeated calls share one call per request. A slow read awaited inside <Suspense> streams instead of holding the page.
An await holds only what renders below it: start independent reads together, share repeats with cache(), and stream the slow part.
How it works
Step 1 of 11
In the kitchen
- asking for the reference only after the home-visit report is back → reads awaited in turn (steps 1, 5)
Analogy: A cat rescue centre preparing one adoption
For each adoption the centre needs three replies from outside: the home-visit volunteer's report, a character reference for the adopter and the cat's history file from its previous owner. A coordinator who asks for the reference only after the home-visit report is back, though the reference needs nothing from it, makes the adopter wait for both in turn. The cattery team and the adoption office start on an adoption at the same moment. The volunteer told to pack the bedding only once the photographer is done starts late. The adopter's ID is checked once per adoption and written in that adoption's folder, and everyone working on it reads it there; the next adoption opens a new folder. The adopter takes the cat home with a slip, "history file: to follow".
| At the rescue centre | In Next |
|---|---|
| asking for the reference only after the home-visit report is back | reads awaited in turn (steps 1, 5) |
| the cattery team and the adoption office starting together | sibling components, a layout and its page (step 3) |
| the volunteer waiting for the photographer | a child under an awaiting parent (step 4) |
| the ID check written in the adoption folder | cache() within one request (step 6) |
| a new folder for the next adoption | a new cache per request (step 7) |
| the cat now, "history file: to follow" | a slow part streamed behind <Suspense> (step 11) |
In real terms: an await holds its component and every child below it; reads that need nothing from each other belong side by side or started together, and repeats share one call per request.
Breaks down when: a folder stays in the cabinet after the adoption, while React drops its cache when the request ends. Wrong inference: "the next request finds the lookup already done."
Contrasting case: a Client Component that fetches in an effect starts its read only after the page has reached the browser and hydrated, a wait no server-side fix removes (the browser cache: fsd-server-state-caching).
Examples, run for real
4.1 ExamHall: the attempt page as found
EXH-3340: the attempt page awaits the candidate (from the session cookie), the exam, the candidate's attempt and the saved answers, one after another, and prints them. The data server holds each read until the script answers, in rounds: each round it replies to every read it holds.
4.1 /attempt as found: reads held per round: round 1 candidate · round 2 exam · round 3 attempt · round 4 answers
4.1 reads in flight at once, at most 1 · first byte after 4 rounds of replies
Each read started only after the previous reply, so four rounds of replies came before the candidate's browser got a byte. The exam needs nothing from the candidate, yet it waited (steps 1, 5).
4.2 Proof domain, payments ledger: one merchant lookup per request
A merchant's statement page has two cards, the balance and the payouts, side by side. Each calls getMerchant(), which reads the session cookie and queries the merchants table.
4.2 plain getMerchant(): merchants queries for one view 2
4.2 wrapped in cache(): one view 1 · a second merchant's view 1 more (2 in all) · each page shows its own merchant yes
As a plain function, each card queried. Wrapped in cache(), the two cards shared one query, and a second merchant's visit queried again and got their own name (steps 6, 7).
4.3 Proof domain, payments ledger: the slow part streams
The statement page prints its heading, then <Suspense fallback={<p>Loading pending payouts…</p>}><PendingPayouts /></Suspense>, and PendingPayouts awaits the slow pending-payouts read. The script holds that read, looks at what has arrived, then releases it.
4.3 pending payouts held: received heading yes · "Loading pending payouts…" yes · payouts no · response ended no
4.3 released: payouts received in the same response yes · status 200
The heading and the fallback reached the browser while the read was still out; the payouts followed in the same response (step 11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
The attempt page renders two components side by side; it awaits nothing itself:
The database holds every read and has replied to none yet.
Which reads has it not received yet?
export default function AttemptPage() { return <main><ExamHeader /><AttemptPanel /></main>;}async function ExamHeader() { const exam = await db.exams.find(); return <div><h1>{exam}</h1><Rules /></div>; }async function Rules() { const rules = await db.rules.find(); return <p>{rules}</p>; }async function AttemptPanel() { const candidate = await db.candidates.find(); return <div><p>{candidate}</p><AttemptStatus candidate={candidate} /></div>; }async function AttemptStatus({ candidate }) { const attempt = await db.attempts.find(candidate); return <p>{attempt}</p>; }Question 2 of 7
A merchant's statement page shows two cards side by side, both rendered per request:
The merchants table logs two lookups for every view of the page.
Which change makes it one?
// lib/merchant.jsexport const getMerchant = cache(async (key) => db.merchants.find(key.id));// the statement pageasync function BalanceCard() { const m = await getMerchant({ id: MERCHANT }); return <Balance m={m} />; }async function PayoutList() { const m = await getMerchant({ id: MERCHANT }); return <Payouts m={m} />; }Question 3 of 7
The attempt page, rendered per request:
The answers' read is slow. Today the candidate sees the heading and "Loading answers…" at once, then the answers.
Which one change makes the browser receive nothing at all until the answers' read returns?
export default function AttemptPage() { return <main> <h1>ECON-201 attempt</h1> <Suspense fallback={<p>Loading answers…</p>}><Answers /></Suspense> </main>;}async function Answers() { const a = await getAnswers(); return <p>Saved answers: {a}</p>; }Question 4 of 7
The statement route, rendered per request. The layout awaits getMerchant() and wraps the page. The page:
None of the four reads needs another's result. The database answers in rounds: each round it replies to every read it holds at that moment.
How many rounds of replies come before the merchant's browser receives its first byte?
export default async function StatementPage() { const balance = await getBalance(); const payouts = await getPayouts(); return <main> <p>{balance}</p><p>{payouts}</p> <Suspense fallback={<p>Loading disputes…</p>}><Disputes /></Suspense> </main>;}async function Disputes() { const d = await getDisputes(); return <p>{d}</p>; }Question 5 of 7
MR !3640 rewrites EXH-3340's attempt page. The first chunk must show the candidate's name and the exam title, and the same response must still bring the attempt and the saved answers.
Write the page so that the candidate's browser gets its first byte after at most 2 rounds of replies.
Question 6 of 7
The statement route's layout and its page each run the same three reads, and none is wrapped in cache():
The page renders per request. A merchant opens it once.
How many requests reach the rates API and the fees API, and how many queries reach the merchants table?
const rate = await (await fetch(RATES_URL)).text(); // a GET to the exchange-rate APIconst fee = await (await fetch(FEES_URL, { method: 'POST', body: 'm-4471' })).text(); // a POST to the fees APIconst merchant = await db.merchants.find('m-4471');Question 7 of 7
The exam header, rendered per request; getExam and getRules are database reads:
The rules need only the exam id.
Which change starts the rules read while the exam read is still out, and still sends one rules query per visit?
async function ExamHeader({ examId }) { const exam = await getExam(examId); return <div><h1>{exam}</h1><Rules examId={examId} /></div>;}async function Rules({ examId }) { const rules = await getRules(examId); return <p>{rules}</p>; }Go deeper
Pro tips
- To find a waterfall, hold every read and look at which ones have started. A data layer that logs each call as it starts, behind a switch that delays every reply, shows in one page view which reads wait for others; a read that starts only after an unrelated reply is the waterfall. When: a page is slow to send its first byte (steps 2–4). Verify (RAN, Node 24.21.0:
lib/data-server.mjsandlib/observe.mjshold and log every read for this lesson's scripts). - Remember that
fetchsharing stops at route handlers. Next memoizesfetchonly inside the React component tree; a route handler that calls the same URL twice makes two requests. When: moving page logic into an API route (step 9). Status: DOC (Next.js docs,fetch: "Memoization does not apply in Route Handlers, since they are not part of the React component tree"); not run.
Source
- Next.js docs, Fetching Data: https://nextjs.org/docs/app/getting-started/fetching-data ·
next-fetching-data.txt. Named in the curriculum row. "By default, layouts and pages are rendered in parallel"; "within any component, multipleasync/awaitrequests can still be sequential if placed after the other";Promise.all; "For data access that does not usefetch, such as an ORM or database query, wrap the function inReact.cache"; "React.cacheis scoped to the current request only. Each request gets its own memoization scope with no sharing between requests"; preloading ("call the data-fetching function withoutawaitbefore blocking work … The data-fetching function must deduplicate matching calls"); streaming with<Suspense>. Supports steps 1–7, 9–11. Valid as of 2026-10-09. - React docs,
cache: https://react.dev/reference/react/cache ·react-cache.txt. Named in the curriculum row. "React will invalidate the cache for all memoized functions for each server request"; "Each call tocachecreates a new function … that do not share the same cache"; "Callcacheoutside of any components"; "React will callObject.ison the input to verify if there is a cache hit"; the preload pattern. Supports steps 6–8, 10. - Next.js docs,
fetch: https://nextjs.org/docs/app/api-reference/functions/fetch ·next-fetch.txt. "fetchrequests usingGETwith the same URL and options are automatically memoized during a server render pass"; "Memoization does not apply in Route Handlers". Supports step 9 and pro tip 2. - Next.js docs, Streaming: https://nextjs.org/docs/app/guides/streaming ·
next-streaming.txt. Background for step 11 (streaming order itself isnext-rendering-modes-ppr's). - Next.js docs, Route segment config and Caching without Cache Components:
next-route-segment-config.txt,next-caching-previous-model.txt.dynamic = 'force-dynamic', as the fixtures use it; "If you are not usingfetch(which is automatically memoized) … wrap your data access with the Reactcachefunction". Background for the fixture and step 9.
Related lessons
Checked against the tools and docs as of 2026-10-09.