async function Page(): Awaiting fetch in the Component That Renders It
In React before the App Router, a component that needed data had to render empty, mount, run an effect, fetch, then render again. The App Router removes the ceremony: a server component may be an async function, and await works normally inside it.
// app/orders/page.tsx (no 'use client' — this runs on the server)
export default async function OrdersPage() {
const res = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
if (!res.ok) throw new Error(`orders fetch failed: ${res.status}`);
const orders = await res.json();
return (
<table>
{orders.map((o) => <tr key={o.id}><td>{o.id}</td><td>{o.total}</td></tr>)}
</table>
);
}
Read that as a sequence: the component function is the data loader. There is no useState, no useEffect, no loading flag, and no typeof window branch — the function runs on the server, blocks its own rendering, and what reaches the browser is HTML with the rows already in it. The secret token in process.env never crosses the network boundary, because only the JSX result is serialized to the client.
The prop contract stays ordinary. Because the component is async, it returns a Promise that React resolves; JSX from awaited data is just JSX. Params still arrive as props to await first:
export default async function OrderPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
const order = await getOrder(id);
return <OrderDetail order={order} />;
}
Error handling is server-side too. A rejected fetch propagates to the nearest error.tsx, so a failed upstream service renders the segment's error boundary instead of a blank table — the same boundary that catches a thrown notFound() for a missing record. You are writing ordinary async code with ordinary failure modes; the router just decides where it runs.
One nuance about when this await happens: without a loading.tsx above it, the page's fetch delays the entire HTML response — the browser sees headers, then nothing until the data resolves, then the whole document. With a loading.tsx in the segment (a Suspense boundary), Next flushes the shell immediately and streams the awaited content when it arrives, so a slow upstream degrades into a skeleton rather than a white screen. The await itself did not change; the boundary around it decides whether the user waits on TTFB or on a visible region.
Two things must be true for this to work, and interviewers ask about both. First, the component must be a server component — no useState, no event handlers, no browser APIs, so no 'use client' directive. The moment you add it, async is off the table (client components cannot be async functions), and you fall back to effects or a data library. Second, the render must be allowed to wait: if the route reads cookies() or uncached fetches, it renders per request; if every input is known at build time, the same await runs at build time and produces static HTML. Same code, different schedule — the subject of Request Memoization and the Data Cache.
The lift from here is composability: a child component can be async as well, so await getOrder(id) can live in <OrderSummary /> instead of the page. That freedom is useful and dangerous in equal measure — which is what the third chapter of this topic is about.
Why the Data Arrives Whole: Dropping useEffect, Spinners, and the First Waterfall
Compare the same orders screen written both ways. The client version renders a spinner, mounts, fires the effect, parses JSON, and commits — at least two paint cycles and a visible flash of emptiness. The async server component version ships rows in the first HTML response. The difference is not cosmetic; it changes what the user measures as "loaded".
What the server component removes, item by item:
Client useEffect fetch |
Async server component |
|---|---|
| Spinner or skeleton while mounted | Skeleton only while streaming (loading.tsx) |
| Empty-first render, then data | Data in the initial HTML |
| Runs in the browser, visible in DevTools | Runs on the server, secret headers stay server-side |
| Needs cleanup, AbortController, race handling | Runs once per render, no race |
| Re-runs on client route change | Runs per request/navigation, cached by the router |
Notice the trade hiding in the last row: an effect re-runs when your component remounts, while a server fetch re-runs when the route renders. They are different invalidation triggers, and confusing them is how you end up refetching on every keystroke.
There is a second, subtler removal: the client-side waterfall. In a useEffect architecture, component A's effect fetches /api/user, and component B (which needs the user's team id) cannot fetch until A finishes and passes the id down. Every dependency costs a network round trip inside the browser. A server component tree awaits in Node against a private network, and dependencies are plain function calls:
async function Dashboard() {
const user = await getUser(); // 40 ms
const team = await getTeam(user.teamId); // runs after; same process, no HTTP hop
return <TeamBoard team={team} />;
}
Even the "sequential" case is cheaper than its client equivalent — no serialization, no CORS, no TLS handshake to the public API. And the independent case gets faster still with Promise.all, which is the next chapter's subject.
What still legitimately fetches in the browser. Data that changes with interaction the server cannot predict: typeahead results, infinite scroll pages, live notifications over a socket, a preview that updates as the user drags a slider. Also anything requiring an Authorization header tied to a client-side token you deliberately do not send to the server. The rule: initial page data comes from the server; interaction-driven data comes from the client. If your first paint depends on an effect, something upstream is wrong — you have either made the route a client component unnecessarily or split data across a boundary that did not need splitting.
Interviewers probe this boundary directly: "when would you use useEffect to fetch in a Next.js app?" A strong answer names the interaction-driven cases and then says, flatly, that the initial render is not one of them.
There is a measurable version of this argument. A client-fetched initial view costs a round trip after HTML parse and hydration: document → JS bundle → effect runs → API call → render. A server-fetched view collapses that to one hop (server → API) that finishes before the HTML ships, and the user's browser starts downloading assets in parallel. On a 4G connection with a 200 kB bundle, the difference between the two is commonly several hundred milliseconds of visible emptiness — which shows up directly in Largest Contentful Paint. If a page's LCP element is fed by a useEffect fetch, you have chosen the slowest possible path to the thing Google measures.
Promise.all, Nested Awaits, and Where the Waterfall Starts Again
Async components make fetching easy, which is exactly how teams reintroduce latency. Look at this dashboard: three independent services, three sequential awaits, 300 ms where 100 would do.
// WRONG: serializes independent work
async function Dashboard() {
const stats = await getStats(); // 100 ms
const activity = await getActivity(); // 100 ms
const alerts = await getAlerts(); // 100 ms
return <Board stats={stats} activity={activity} alerts={alerts} />;
}
Each await suspends the function until its promise settles, so the total is the sum. Wrapping the independent reads in Promise.all starts them together and the wall time becomes the slowest one, not the sum:
const [stats, activity, alerts] = await Promise.all([
getStats(), getActivity(), getAlerts(),
]); // ~100 ms total
The rule for deciding is simple: if a later read does not use an earlier read's value, they are independent. The moment getActivity(user.id) needs user from getUser(), that dependency is real and must be awaited in order — the code below the line depends on the value above it. Partial parallelism is normal and worth doing:
const user = await getUser(); // required first
const [orders, prefs] = await Promise.all([
getOrders(user.id), getPrefs(user.id),
]); // both need only user.id
A Promise.all rejects on the first failure, discarding the other results. When one service is optional, use Promise.allSettled and treat a rejected leg as a default instead of failing the whole page:
const results = await Promise.allSettled([getStats(), getAlerts()]);
const stats = results[0].status === 'fulfilled' ? results[0].value : EMPTY_STATS;
Then there are the waterfalls that are not visible in one file — the N+1 pattern. A parent renders a list and every child does its own await:
// 1 + N round trips: parent fetches posts, each child fetches its author
function PostList({ posts }) {
return posts.map((p) => <PostRow key={p.id} post={p} />); // PostRow is async
}
async function PostRow({ post }) {
const author = await getAuthor(post.authorId); // 20 queries for 20 posts
...
}
Three fixes, in order of preference: have the parent's query include the author (one request); batch in the data layer (WHERE id IN (...), DataLoader-style dedupe); or at minimum fire the child awaits concurrently so the twenty queries overlap instead of queueing. Lifting the fetch into the parent is usually best because it also gives you one place to handle failure.
The systematic habit: after writing an async tree, count the awaits on the critical path and ask which are required to be sequential. In interviews this question arrives as "how would you speed up this page?", and a strong answer names Promise.all for siblings, batching for N+1, and moving independent work into parallel loading.tsx boundaries so the shell streams while the rest resolves.
Fan-out deserves one pattern of its own, because it hides inside map:
// 20 concurrent upstream calls — fine, or a thundering herd?
const authors = await Promise.all(
posts.map((p) => getAuthor(p.authorId)),
);
Promise.all starts all twenty immediately. For an in-process cache or a local DB that is ideal; for a third-party API with a rate limit it is how you earn 429 responses. Bound the concurrency when the target is the bottleneck — chunk the array, use a small worker pool, or better, make one batched call. The same applies to the failing-fetch case: allSettled plus a per-leg default keeps one dead service from taking down the dashboard, and the fallback (EMPTY_STATS, a cached snapshot) belongs next to the data access, not sprinkled through the JSX.
Quiz
1. You add 'use client' to a component and then declare it `export default async function Page()`. What happens?
2. Three independent fetches take 100 ms each. How are they awaited to finish in ~100 ms total?
3. A list renders 20 async children, each calling getAuthor(post.authorId). What pattern is this and what is the primary fix?
4. Compared with a useEffect fetch for initial page data, what does an async server component remove?
Flashcards
Question
What makes a component able to await data directly, and what disqualifies it?
Click to reveal answer
Answer
Being a server component: an async function that awaits fetch (or a DB call) and returns JSX once resolved. 'use client' disqualifies it — client components cannot be async — so they use effects, use(), or receive data as props from a server parent.
Question
Name three things an async server component eliminates versus a useEffect fetch for initial data.
Click to reveal answer
Answer
The empty-first render with a spinner (data is in the initial HTML), the browser-visible network hop with its token exposure, and the client-side waterfall of dependent effects. Also: no cleanup/AbortController, no race handling — the fetch runs once per render.
Question
When is Promise.all required, and when must awaits stay sequential?
Click to reveal answer
Answer
Independent reads (a later one does not use an earlier result) should run under Promise.all so total time is the slowest leg. Dependent reads must be ordered — getActivity(user.id) needs user first. Mix freely: await the dependency, then Promise.all the rest.
Question
Promise.all vs Promise.allSettled for page data?
Click to reveal answer
Answer
all rejects on the first failure and discards other results — right when every read is required. allSettled resolves with {status, value|reason} for each — right for optional legs, where you substitute a default instead of failing the whole page.
Question
What is the N+1 fetch pattern in a component tree, and the fix order?
Click to reveal answer
Answer
A parent fetches the list, then each child fetches its own related record: 1 + N round trips. Fix order: join/include the data in the parent query, batch with a single WHERE id IN (...) (DataLoader-style), or at least overlap the child awaits with Promise.all.
Revision Notes
Key Takeaways
- 1. A server component can be an async function — await fetch inside it and the browser receives HTML with the data already present
- 2. 'use client' ends the story: client components cannot be async, so move the fetch to a server parent and pass props down
- 3. Sequential awaits on independent reads multiply latency; Promise.all makes the total the slowest leg, allSettled protects optional legs
- 4. Nested async children doing one query each is the N+1 pattern — join or batch before you optimize anything else
- 5. Secrets stay on the server because only JSX crosses the boundary; failures still surface through error.tsx like any other thrown error
Interview Tips
- • "How do you fetch data in the App Router?" — async server component, await directly, note the constraints (no 'use client', params is a Promise, failures hit error.tsx)
- • "When do you still use useEffect?" — interaction-driven data: typeahead, infinite scroll, sockets; explicitly say initial page data should not come from an effect
- • "This page takes 900ms — how do you fix it?" — count the critical path: Promise.all for independent awaits, batch the N+1, and add loading.tsx boundaries so the shell streams first
- • "Doesn't awaiting block the server?" — yes, that render waits; mitigate with caching and revalidation (next topic), and streaming via Suspense so the user sees the shell early
Cheat Sheet
Fetching Data in Async Components
The basic shape:
// server component — no 'use client'
export default async function Page(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
const res = await fetch(`/api/things/${id}`); // secret headers stay server-side
if (!res.ok) throw new Error(`upstream ${res.status}`); // -> error.tsx
const data = await res.json();
return <View data={data} />;
}
Failures propagate to the nearest error.tsx; notFound() renders not-found.tsx.
Effects vs server fetch:
| useEffect | async server component |
|---|---|
| empty render + spinner | data in initial HTML |
| request visible in browser | runs in Node, tokens hidden |
| refetch on remount | refetch when the route renders |
| cleanup + race handling | runs once per render |
Parallelism:
const user = await getUser(); // dependency: sequential
const [orders, prefs] = await Promise.all([ // independent: concurrent
getOrders(user.id), getPrefs(user.id),
]);
const safe = await Promise.allSettled([optA()]); // optional leg -> default on reject
N+1: parent list + child getAuthor = 1 + N queries. Fix: join in the parent, batch with WHERE id IN (...), or overlap the child awaits.
Split by interaction: initial data -> server; typeahead/scroll/live updates -> client. Keep 'use client' components synchronous and pass them plain data.