Concept
The beginner framing: Server Components can fetch their own data directly, turn the component into an async function and await whatever you need, whether that's a fetch call or a direct database query.
The precise mental model: this works because Server Components run once, server-side, per request, the same reasoning covered in Server Components for why they can safely await. Two details make this genuinely different from fetching in a useEffect, though: automatic memoization and the sequential-vs-parallel trap.
// app/blog/page.tsx
export default async function Page() {
const data = await fetch("https://api.vercel.app/blog");
const posts = await data.json();
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}// Direct database access, credentials never reach the client bundle
import { db, posts } from "@/lib/db";
export default async function Page() {
const allPosts = await db.select().from(posts);
return <ul>{allPosts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}Identical fetch calls are automatically deduplicated
Identical fetch requests anywhere in the same component tree render pass are memoized automatically, this means you can call the same data-fetching function from multiple components that each need it, instead of fetching once and drilling props down through everything in between.
Sequential vs. parallel: the single most common performance mistake
// ❌ SEQUENTIAL, getAlbums waits for getArtist to finish first, even though they're unrelated
const artist = await getArtist(username);
const albums = await getAlbums(username);// ✅ PARALLEL, both requests start immediately; Promise.all waits for both together
const artistPromise = getArtist(username); // NOT awaited yet, just STARTS the request
const albumsPromise = getAlbums(username); // starts immediately too, doesn't wait for the line above
const [artist, albums] = await Promise.all([artistPromise, albumsPromise]);The difference is entirely about when each request is initiated, not when it's awaited, calling the async function itself starts the request; the await only blocks on reading its result.
Streaming to Client Components with use()
<Suspense fallback={<Spinner />}><ProfileDetails /></Suspense>
ProfileDetails calls a special resource-reading function to get its data. This is the very first render attempt.
// Server Component, does NOT await the data fetch
export default function Page() {
const postsPromise = getPosts(); // a promise, deliberately unresolved here
return (
<Suspense fallback={<div>Loading...</div>}>
<Posts posts={postsPromise} />
</Suspense>
);
}// Client Component, reads the promise with use()
"use client";
import { use } from "react";
export default function Posts({ posts }: { posts: Promise<Post[]> }) {
const allPosts = use(posts); // suspends until the promise resolves, same mechanism as Suspense
return <ul>{allPosts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}This is the exact throw-a-promise/catch-with-Suspense mechanism from Suspense, just crossing the server-to-client boundary: the Server Component starts the fetch and hands over the promise itself (not the resolved data), letting the Client Component decide when to actually suspend on it.
Sharing fetched data across Server and Client Components: React.cache + Context
export const getUser = cache(async () => fetch("https://api.example.com/user").then((r) => r.json()));Pass the unresolved promise into a Client Component Context Provider; any Client Component can then read it with use(), while any Server Component can call getUser() directly, since it's wrapped in React.cache, every caller within the same request gets the same underlying result, with the actual fetch happening only once.
Try It
Predict the total time before checking the solution.
async function getUser() {
await sleep(300); // simulates a 300ms request
return { name: "Ada" };
}
async function getPosts() {
await sleep(300); // simulates a 300ms request, UNRELATED to getUser
return [{ title: "Hello" }];
}
export default async function Page() {
const user = await getUser();
const posts = await getPosts();
return <div>{user.name}: {
Roughly how long does this page take to render, 300ms, or 600ms?
Solution
About 600ms. Even though getUser() and getPosts() don't depend on each other at all, writing them as two separate await statements in sequence means getPosts() isn't even called until getUser()'s await has fully resolved, the requests run one after another, not concurrently. Rewriting as const [user, posts] = await Promise.all([getUser(), getPosts()]); would start both immediately, bringing the total time down to roughly 300ms, the duration of the slower of the two, not their sum.
Implement It Yourself
Build a minimal timer to see the sequential-vs-parallel difference directly:
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function sequential() {
console.time("sequential");
const a = await delay(300, "a");
const b = await delay(300, "b");
console.timeEnd("sequential"); // ~600ms
return [a, b];
}
async function
The only structural difference is when each promise-returning call happens relative to its own await, calling both functions before awaiting either is what actually achieves concurrency.
Under the Hood
The sequential-vs-parallel distinction is pure Promises/Async, Await behavior, nothing Next.js-specific is happening; a promise-returning function call starts its work immediately regardless of when (or whether) you await it, and Promise.all is the standard way to wait on several concurrently-running promises together. And passing an unresolved promise from a Server Component into a Client Component's use() call is exactly the streaming pattern from Suspense, the promise itself crosses the server/client boundary as a prop, and use() is what lets the Client Component suspend on it at the moment it chooses to.
Common Mistakes
1. Writing independent data requests as sequential awaits
Covered in Try It, the single most common data-fetching performance mistake in Server Components. If two pieces of data don't depend on each other, initiate both before awaiting either.
2. Assuming fetch is cached by default
const data = await fetch("https://api.example.com/data"); // NOT cached, blocks every requestPlain fetch calls aren't cached by default and will block rendering until they resolve, every time. Use "use cache" (see Caching) to actually cache the result, or wrap the fetching component in <Suspense> to stream fresh data without blocking the rest of the page.
3. Awaiting a promise in the Server Component before passing it to a Client Component that wants to stream it
const posts = await getPosts(); // ❌ already resolved, defeats the point of use()
return <Posts posts={posts} />;If the goal is to let a Client Component suspend on the data itself (streaming it in), the Server Component must pass the unresolved promise, not await it first, awaiting first blocks the Server Component's own render on that data, exactly the delay streaming was meant to avoid.
Best Practices
- Start independent requests together, using
Promise.all(or simply calling both functions before awaiting either) whenever two pieces of data don't depend on each other. - Reach for
React.cachewhen the same data is needed by both a Server Component and, via a Client Component context provider, other parts of the tree, one underlying fetch, many callers. - Pass unresolved promises to Client Components specifically when you want them to control their own suspend/stream timing via
use(). - Handle a request that MUST resolve before anything else can be shown by wrapping the whole page (or that section) in its own
loading.tsx/<Suspense>, rather than trying to force it to be non-blocking when it structurally can't be.
Performance Tips
- The Promise.all rewrite from Try It is often the single highest-leverage fix available for a slow Server Component, it costs nothing structurally and can roughly halve (or better) the wait time for genuinely independent requests.
- Automatic
fetchmemoization means colocating a data-fetching call in every component that needs it (rather than fetching once and prop-drilling) has no duplicate-request penalty, as long as the calls are identical, a real simplification, not just a convenience. - Streaming via
use()(orloading.tsx/<Suspense>generally) decouples a slow, unrelated piece of data from blocking the rest of an otherwise-fast page, reach for it whenever one dependency is meaningfully slower than the rest.
