Data Fetching and Streaming
How Server Components fetch data in Next.js 16, why params, searchParams, cookies() and headers() are promises, how fetch is deduplicated per request, how loading.tsx and Suspense stream the page, and how to avoid waterfalls and hand promises to Client Components with use().
- Published
- Reading time
- 12 min read
In the App Router you fetch data by making a Server Component async and awaiting whatever you like: fetch, an ORM, a file read. Nothing is cached by default. Identical GET fetches in one request are deduplicated by a React.cache-scoped map that Next.js wraps around globalThis.fetch. For other data sources you wrap the function in React.cache yourself. params, searchParams, cookies() and headers() are promises, because during a prerender Next.js hands out promises that never resolve when the value is not known yet. That is how it finds the dynamic parts of a page. Each <Suspense> boundary, and each loading.tsx (which becomes a <Suspense> around the segment's children), is a separate streaming point: the fallback goes out in the first chunk, and the content follows when its data arrives. Start independent requests before awaiting any of them, push slow reads down behind their own boundary, and when a Client Component needs the data, pass it the promise and read it there with use().
The earlier parts built the route tree (App Router) and drew the server/client line (Server and Client Components). This part puts data into that tree. It explains how a request gets its data, what Next.js does with the fetch you call, why the request APIs became promises in Next.js 15 and 16, and how streaming turns slow data into a page that paints in pieces.
The running example is the same small notes app used across the course: /notes lists notes, /notes/[id] shows one note with its comments and an author card.
Fetching in a Server ComponentCopy link to section:
A Server Component is just an async function that returns JSX. You await data inside it:
// app/notes/page.tsx
import { db } from '@/lib/db'
export default async function NotesPage() {
const notes = await db.note.findMany({ orderBy: { updatedAt: 'desc' } })
return (
<ul>
{notes.map((note) => (
<li key={note.id}>{note.title}</li>
))}
</ul>
)
}There is no getServerSideProps, no loader file and no special data hook. The component runs on the server, so credentials and query code never reach the browser. The result is serialized into the RSC payload along with the rest of the tree.
Two defaults matter in Next.js 16:
- Nothing is cached. A plain
fetchor database call runs every time the component renders. Caching is opt-in with'use cache'(Cache Components). This changed in Next.js 15, whenfetchstopped defaulting toforce-cache. - Identical
GETfetches are deduplicated within one request. Two components can both callfetch('/api/notes/1')and only one network request goes out. This is memoization for the lifetime of one render, not a cache.
With cacheComponents: true, a route is still prerendered at build time. An uncached read simply cannot be part of that prerender, so it must sit inside a <Suspense> boundary and stream at request time. Without such a boundary the build fails with a "blocking route" error. More on this below and in PPR.
How fetch is deduplicatedCopy link to section:
When the server starts, patchFetch replaces globalThis.fetch. The first layer of that replacement is createDedupeFetch, which is adapted from React's own ReactFetch.js.12
// packages/next/src/server/lib/dedupe-fetch.ts (simplified)
export function createDedupeFetch(originalFetch: typeof fetch) {
const getCacheEntries = React.cache((url: string): CacheEntry[] => [])
return function dedupeFetch(resource, options) {
if (options && options.signal) return originalFetch(resource, options)
const request = new Request(resource, options)
if ((request.method !== 'GET' && request.method !== 'HEAD') || request.keepalive) {
return originalFetch(resource, options)
}
const cacheKey = generateCacheKey(request) // method, headers, mode, redirect, ...
const cacheEntries = getCacheEntries(request.url)
// reuse a matching in-flight promise, clone its Response, or start a new one
}
}Three details follow from this code:
- The map lives inside
React.cache, so it is scoped to the current React render. A new request gets a new, empty map. Nothing survives across requests. - The key is the URL plus the method, headers (except
traceparentandtracestate), mode, redirect, credentials, referrer, referrer policy and integrity.3 Two fetches to the same URL with differentAuthorizationheaders are two requests. - Passing an
AbortSignalopts out, and so doPOST,PUT,DELETEandkeepaliverequests.4 A request with side effects is never collapsed into another one.
Each caller receives its own clone of the Response, so both can call .json().
React.cache for everything elseCopy link to section:
An ORM call is not a fetch, so nothing deduplicates it for you. Wrap the function in React's cache:
// lib/data/notes.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'
export const getNote = cache(async (id: string) => {
return db.note.findUnique({ where: { id }, include: { author: true } })
})Now the page, generateMetadata and the author card can all call getNote(id) and the query runs once per request. React.cache compares arguments by identity (Object.is), so pass primitives like the id string, not a freshly built object.
React.cache is not a data cacheIt memoizes for one server render and is then thrown away. To keep results across requests you need 'use cache'. Inside a 'use cache' function, React.cache also gets a fresh, isolated scope, so you cannot smuggle a value into a cached function through it. See Cache Components.
Request data is a promiseCopy link to section:
In Next.js 16, every request-specific input is asynchronous:
// app/notes/[id]/page.tsx
import { cookies } from 'next/headers'
export default async function NotePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ tab?: string }>
}) {
const { id } = await params
const { tab = 'comments' } = await searchParams
const theme = (await cookies()).get('theme')?.value
// ...
}cookies(), headers() and draftMode() return promises,5 and so do the params and searchParams props of pages and layouts. Next.js 15 introduced the async versions next to a temporary synchronous fallback. Next.js 16 removed the synchronous access, so params.id without await no longer works.
The reason is the prerender. To build a static shell, Next.js renders the route without a request. When it reaches something it cannot know yet, it needs a way to stop that branch without stopping the whole render. A promise that never resolves does exactly that: the component that awaits it suspends forever, React renders the nearest <Suspense> fallback in its place, and the rest of the page completes. PPR shows how those holes are found.
You can see it in the code that creates params. During a Cache Components prerender, if any param is a fallback param (a value not listed by generateStaticParams), Next.js returns a hanging promise instead of the values:6
// packages/next/src/server/request/params.ts (simplified)
function createStaticPrerenderParams(underlyingParams, ..., prerenderStore) {
case 'prerender': {
if (hasFallbackRouteParams(underlyingParams, prerenderStore.fallbackRouteParams)) {
return makeHangingParams(underlyingParams, workStore, prerenderStore)
}
// ...all params known at build time:
return makeUntrackedParams(userspaceParams) // Promise.resolve(params), cached per object
}
}searchParams is never known at build time, so in a Cache Components prerender it is always a hanging promise.7 The same pattern applies to an uncached fetch. When a fetch with no cache configuration runs inside a prerender, patchFetch does not even send it. It returns a hanging promise right away, because the result could never be part of the shell.8
Diagram source:
flowchart TD
P["Prerender at build"] --> A{"What does the<br/>component await?"}
A -- "static value or<br/>'use cache' hit" --> S["Resolves: part of<br/>the static shell"]
A -- "searchParams, cookies(),<br/>uncached fetch" --> H["Hanging promise"]
H --> B{"Inside a<br/>Suspense boundary?"}
B -- yes --> F["Fallback goes in the shell,<br/>content streams per request"]
B -- no --> E["Build error:<br/>blocking route"]When an access like that is not covered by a boundary, the error text spells out the options: wrap the access in <Suspense>, cache it with 'use cache' (for uncached data, not for cookies()), or set export const instant = false to accept a blocking route.9
Because params and searchParams are promises, you can pass them down unresolved: <Comments params={params} />. The child awaits them inside its own <Suspense>. The page itself never touches the value, so it stays in the static shell.
Streaming with loading.tsx and SuspenseCopy link to section:
Streaming means the server sends HTML in pieces. The first chunk contains everything that rendered without waiting: layouts, static markup and the fallbacks of every <Suspense> boundary whose content was not ready. When a boundary's content finishes, React streams that HTML plus a small inline script that swaps it in for the fallback. The browser applies the swap before the JavaScript bundle has even loaded. What React does when it renders and commits a tree is covered in React Concepts: rendering. This section is about where Next.js places the boundaries.
loading.tsx is a Suspense boundaryCopy link to section:
A loading.tsx next to a page.tsx streams the whole segment:
// app/notes/[id]/loading.tsx
export default function Loading() {
return <NoteSkeleton />
}There is no special loading mechanism behind it. The router renders each segment inside a fixed stack of client components, and one of them is LoadingBoundary, which turns the module into a plain React Suspense fallback:10
// packages/next/src/client/components/layout-router.tsx (simplified)
function LoadingBoundary({ loading, children }) {
if (loading !== null) {
const [loadingRsc, loadingStyles, loadingScripts] = loading
return (
<Suspense fallback={<>{loadingStyles}{loadingScripts}{loadingRsc}</>}>
{children}
</Suspense>
)
}
return <>{children}</>
}The order of the stack explains several behaviours people trip over:11
Diagram source:
flowchart TD L["layout.tsx of the parent"] --> E["ErrorBoundary<br/>(error.tsx)"] E --> LB["LoadingBoundary<br/>(loading.tsx)"] LB --> N["HTTPAccessFallbackBoundary<br/>(not-found, forbidden, unauthorized)"] N --> R["RedirectBoundary"] R --> C["the segment:<br/>layout + page"]
- The loading boundary sits below the parent layout and wraps the child segment. A
loading.tsxinapp/notes/[id]/covers that folder'spage.tsxand any nested segments. It does not coverapp/notes/[id]/layout.tsx's own data, because the layout of the same folder renders the boundary, not inside it. - So a layout that awaits
cookies()or an uncached fetch is not covered by its siblingloading.tsx. It blocks the navigation. Move that read into the page, or wrap it in its own<Suspense>inside the layout. error.tsxwraps the loading boundary, so an error thrown while the content streams in still lands in the segment's error UI.
On a client navigation the same boundary is what makes the UI respond immediately: the router shows the loading.tsx fallback (prefetched with the route, see Navigation) while the new segment's payload streams in.
Suspense for finer controlCopy link to section:
loading.tsx is coarse: one fallback for the whole page. A <Suspense> boundary can wrap exactly the slow part:
// app/notes/[id]/page.tsx
import { Suspense } from 'react'
import { getNote } from '@/lib/data/notes'
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const note = await getNote(id) // fast, needed for the heading
return (
<article>
<h1>{note.title}</h1>
<NoteBody note={note} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments noteId={id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedNotes noteId={id} />
</Suspense>
</article>
)
}Sibling boundaries are independent streaming points. Comments and RelatedNotes start together and each one streams in as soon as its own data is ready, in whatever order they finish.
A boundary only matters when something inside it suspends. A component that does only synchronous work completes during the prerender whether or not it is wrapped in <Suspense>.
What streaming cannot doCopy link to section:
Once the first byte is sent, the status code and headers are fixed. If notFound() fires inside a boundary that has already streamed its fallback, Next.js cannot turn the 200 into a 404. It injects <meta name="robots" content="noindex"> instead, and a redirect() there becomes a client-side redirect. Call notFound() or redirect() before the first await that sits under a boundary if you need the real status code (Errors and Redirects).
HTML-limited bots and crawlers are also treated differently: Next.js waits for generateMetadata before streaming the body for them, so the metadata lands in <head> (Metadata).
Sequential vs parallel fetchingCopy link to section:
Streaming hides latency. It does not remove it. The other half is making sure requests do not wait on each other for no reason.
The accidental waterfallCopy link to section:
// app/notes/[id]/page.tsx: sequential (slow)
const { id } = await params
const note = await getNote(id) // 80 ms
const comments = await getComments(id) // 120 ms, starts only after getNote
const author = await getAuthor(note.authorId) // 60 ms, really depends on note
// total: about 260 msgetComments does not need note, but it waits for it anyway because of where the await is.
Start first, await laterCopy link to section:
Calling an async function starts the work. Awaiting only waits for it. So start everything that is independent, then wait for all of it:
// app/notes/[id]/page.tsx: parallel
const { id } = await params
const notePromise = getNote(id)
const commentsPromise = getComments(id)
const [note, comments] = await Promise.all([notePromise, commentsPromise])
const author = await getAuthor(note.authorId) // a real dependency, stays sequential
// total: about 120 + 60 = 180 msUse Promise.allSettled when one failure should not throw away the others.
Let the tree parallelize for youCopy link to section:
Separate components are the cleanest form of parallel fetching. Next.js builds the element for every segment (layout, page, parallel route slots) up front and gives each segment's content to React as its own node in the payload, so a layout's slow await does not delay the page from starting its own fetches.12 Inside one segment, sibling Server Components that each fetch their own data start at the same time as React renders them, and sibling <Suspense> boundaries stream independently.
Diagram source:
sequenceDiagram participant B as Browser participant S as Server participant DB as Database B->>S: GET /notes/42 S->>DB: getNote(42) S->>DB: getComments(42) S->>DB: getRelated(42) DB-->>S: note S-->>B: shell + heading + fallbacks DB-->>S: related S-->>B: related HTML + swap script DB-->>S: comments S-->>B: comments HTML + swap script
Preloading behind a blocking checkCopy link to section:
Sometimes a request must wait for a check, but the data for what comes after could start now. Start it early without awaiting it, and let the consumer pick up the same in-flight call through deduplication:
// app/notes/[id]/note-body.tsx
import { getNote } from '@/lib/data/notes' // wrapped in React.cache
export const preloadNote = (id: string) => {
void getNote(id)
}
export async function NoteBody({ id }: { id: string }) {
const note = await getNote(id) // reuses the promise started by preloadNote
return <div>{note.body}</div>
}// app/notes/[id]/page.tsx
import { NoteBody, preloadNote } from './note-body'
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
preloadNote(id) // starts now
const allowed = await canRead(id) // runs at the same time
return allowed ? <NoteBody id={id} /> : null
}This only works if the function deduplicates: a plain fetch, a React.cache wrapper, or a 'use cache' function.
Handing data to Client Components with use()Copy link to section:
Client Components cannot be async, but they can read a promise with React's use. The pattern is: start the request in a Server Component, do not await it, and pass the promise down as a prop.
// app/notes/[id]/page.tsx (Server Component)
import { Suspense } from 'react'
import { getComments } from '@/lib/data/comments'
import { CommentList } from './comment-list'
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const commentsPromise = getComments(id) // not awaited
return (
<Suspense fallback={<CommentsSkeleton />}>
<CommentList commentsPromise={commentsPromise} />
</Suspense>
)
}// app/notes/[id]/comment-list.tsx
'use client'
import { use, useState } from 'react'
type Comment = { id: string; body: string; author: string }
export function CommentList({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise)
const [sort, setSort] = useState<'new' | 'old'>('new')
const sorted = sort === 'new' ? comments : [...comments].reverse()
return (
<section>
<button onClick={() => setSort(sort === 'new' ? 'old' : 'new')}>Sort: {sort}</button>
<ul>{sorted.map((c) => <li key={c.id}>{c.body}</li>)}</ul>
</section>
)
}A promise is one of the values the RSC protocol can serialize (Server and Client Components). The server sends a placeholder for it, keeps the request open, and streams the resolved value into the same response when it settles. On the client, use suspends until that row arrives, and the surrounding <Suspense> shows its fallback meanwhile.
Prefer this over fetching in useEffect. The request starts on the server during the same render, there is no second round trip after hydration, and the data never goes through a public API route. If the promise rejects, the nearest error boundary catches it.
Put the promise in a context provider in a layout and read it with use(useContext(Ctx)) where needed. Every consumer suspends on the same request. Client data libraries like TanStack Query take this further with server prefetch and hydration, see TanStack Query.
Choosing where the data comes fromCopy link to section:
SummaryCopy link to section:
A Server Component fetches data by awaiting it, and in Next.js 16 nothing is cached unless you opt in. patchFetch wraps fetch in a per-render React.cache map that collapses identical GET requests, and React.cache does the same for your own functions. params, searchParams, cookies() and headers() are promises so that a prerender can hand out promises that never resolve for unknown values, which suspends exactly the components that need them. loading.tsx is a Suspense boundary placed by the layout router around a segment's children, and every <Suspense> is an independent streaming point. Start independent work before awaiting it, keep slow reads behind their own boundary, and pass promises to Client Components to read with use(). The next part turns the uncached reads into cached ones with 'use cache'.
Key takeawaysCopy link to section:
- No default caching: a plain
fetchor ORM call runs on every render in Next.js 16; caching is opt-in with'use cache'. createDedupeFetch: identicalGET/HEADfetches in one render share one request; asignal, a non-GET method orkeepaliveopts out.React.cache: per-request memoization for non-fetch data access; it is not a cross-request cache.- Async request APIs:
params,searchParams,cookies()andheaders()are promises; synchronous access was removed in Next.js 16. - Hanging promises: during a prerender, unknown params,
searchParamsand uncached fetches never resolve, which is how Next.js finds the dynamic holes. loading.tsx: becomesLoadingBoundary, aSuspensearound the segment's children, so it does not cover its own folder's layout.- Parallel by structure: start promises before awaiting, use
Promise.all, and split slow reads into sibling components and boundaries. - Preloading: call a deduplicated fetcher without awaiting it before a blocking check, then await it in the consumer.
use()in Client Components: pass an unawaited promise from the server; it streams into the same response.
← Navigation · Index · Cache Components →
Footnotes
-
patchFetchwrapsglobalThis.fetchwithcreateDedupeFetchand the caching fetcher — packages/next/src/server/lib/patch-fetch.ts#L1389-L1399 (opens in a new tab) ↩ -
createDedupeFetch— packages/next/src/server/lib/dedupe-fetch.ts#L43-L131 (opens in a new tab) ↩ -
generateCacheKeyand the excluded tracing headers — packages/next/src/server/lib/dedupe-fetch.ts#L9-L35 (opens in a new tab) ↩ -
opt-outs for
signal, non-GET/HEAD andkeepalive— packages/next/src/server/lib/dedupe-fetch.ts#L56-L91 (opens in a new tab) ↩ -
createStaticPrerenderParams— packages/next/src/server/request/params.ts#L321-L408 (opens in a new tab);makeHangingParamsandmakeUntrackedParams— #L717-L755 (opens in a new tab) ↩ -
createStaticPrerenderSearchParamsreturnsmakeHangingSearchParamsin a Cache Components prerender — packages/next/src/server/request/search-params.ts#L212-L235 (opens in a new tab) ↩ -
uncached
fetchinside a prerender returnsmakeDynamicHangingPromise— packages/next/src/server/lib/patch-fetch.ts#L645-L664 (opens in a new tab) ↩ -
createRuntimeBodyErrorandcreateDynamicBodyError— packages/next/src/server/app-render/blocking-route-messages.ts#L1-L22 (opens in a new tab) ↩ -
LoadingBoundary— packages/next/src/client/components/layout-router.tsx#L465-L500 (opens in a new tab) ↩ -
boundary stack around each segment in
OuterLayoutRouter— packages/next/src/client/components/layout-router.tsx#L666-L709 (opens in a new tab) ↩ -
createComponentTreeInternalbuilds every parallel route withPromise.alland keeps each child as a separate transport node — packages/next/src/server/app-render/create-component-tree.tsx#L527-L755 (opens in a new tab) ↩