Prerendering and Partial Prerendering
How Next.js 16 prerenders with Cache Components: the work unit stores, hanging promises for cookies(), aborting, the postponed state, and resuming.
- 01Name the store a render runs inSay which work unit store a prerender, a request, a cache and a runtime prefetch use, and how request-time APIs read it.
- 02Explain how a prerender stopsFollow a hanging promise from cookies() to the abort that ends the prerender, and say when that becomes a hole or a build error.
- 03Read the postponed stateTake a recorded .meta file apart, and trace how next start sends the shell and resumes the rest with it.
What the build does with request-time code
Partial Prerendering showed the result from outside: next build makes a static shell (the HTML it can make without a request), leaves each Suspense boundary whose content waits on uncached or runtime data as a hole, and fills the holes for each visitor. Inside Next.js 16.4.3, four things make it work: how code like cookies() notices it's running during the build, how the build stops there, what it saves, and how next start picks it up again.
The app is the tea shop's Tea of the day page. Its heading and its price, from a 'use cache' function, are in the shell; a greeting reads the visitor's name from a cookie, inside a Suspense boundary. One more file, lib/store-type.ts, is a probe: it prints the name of the internal store each part of the page renders in. This is a real Next.js 16.4.3 project, built with next build and run with next start:
app/teas/today/page.tsximport { Suspense } from "react";import { cookies } from "next/headers";import { getPrice } from "@/lib/get-price";import { storeType } from "@/lib/store-type"; async function Greeting() { const name = (await cookies()).get("name")?.value ?? "guest"; return <p>Welcome back, {name}. (store: {storeType()})</p>;} export default async function TodayPage() { const price = await getPrice(); return ( <main> <h1>Tea of the day: Green tea</h1> <p>{price}</p> <p>Shell rendered in store: {storeType()}</p> <Suspense fallback={<p>Finding your name…</p>}> <Greeting /> </Suspense> </main> );}$4 a box (store: cache)
Shell rendered in store: prerender
Welcome back, Asha. (store: request)
Sent with cookie: name=Asha. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
In app/teas/today/page.tsx, lines 6 to 9 are Greeting, which awaits cookies(); lines 18 to 20 wrap it in Suspense. Line 12 awaits getPrice(), a cached function in lib/get-price.ts. Open the Terminal tab: the build lists ◐ /teas/today. Then the Browser tab, which shows Asha's visit, with a name=Asha cookie:
Tea of the day: Green tea
$4 a box (store: cache)
Shell rendered in store: prerender
Welcome back, Asha. (store: request)Three different stores in one page. Line 2: getPrice's body ran in a store called cache. Line 3: the shell rendered in prerender, during next build. Line 4: Greeting rendered in request, at next start, for Asha. The probe is three lines:
import { workUnitAsyncStorage } from "next/dist/server/app-render/work-unit-async-storage.external";
// A probe for this lesson only: it reads Next.js's internal store.
export function storeType() {
return workUnitAsyncStorage.getStore()?.type ?? "none";
}
Line 1 imports workUnitAsyncStorage from an internal file of Next.js, and line 5 reads the current store's type. It's here to make the invisible visible. Never put an internal import like this in an app: it isn't an API, and any release can change it.
type is what cookies(), connection() and the probe read.Before service, a kitchen preps trays: everything that's the same for every guest. Each cook checks the slip clipped above the stove. When a dish needs the guest's name, the cook leaves a numbered gap and writes down where the tray stopped. When Asha sits down, the slip says her table, and the kitchen fills only the gaps.
- In real life: The order slip clipped above the stove
- In Next.js: The work unit store, which any code in the render can read
- In real life: "Prep" on the slip: cook what doesn't depend on the guest
- In Next.js: type: 'prerender' during next build
- In real life: The guest's name: leave a numbered gap on the tray
- In Next.js: cookies() hangs; Suspense leaves a hole
- In real life: The note of where the tray stopped
- In Next.js: The postponed state in the .meta file
- In real life: "Table 4, Asha" on the slip: finish the gaps
- In Next.js: type: 'request' at next start, resuming
Every render runs inside a work unit store whose type says what kind of render it is. During next build it's prerender, and request-time APIs like cookies() return promises that stay pending until the render aborts, then reject. Next.js renders, aborts, and turns every part still waiting into a hole (or a build error, without Suspense). It saves where React stopped as the postponed state, and next start sends the shell and resumes from it.
The work store and the work unit store
cookies() takes no arguments, yet it knows which request it belongs to. The answer is Node.js's AsyncLocalStorage: a value you start a function with (storage.run(value, fn)), which any code that function calls can read back with storage.getStore(), even after await. Next.js keeps two of them.
- The work store,
workAsyncStorage: one per route being rendered. It holds the route and its settings. - The work unit store,
workUnitAsyncStorage: one per render, or per cached function. Itstypesays what kind of render it is.
The work store's type starts like this, in work-async-storage.external.ts, lines 18 to 28:
export interface WorkStore {
/**
* The page that is being rendered. This relates to the path to the page file.
*/
readonly page: string
/**
* The route that is being rendered. This is the page property without the
* trailing `/page` or `/route` suffix.
*/
readonly route: stringLine 5 is the page's file and line 11 the route, /teas/today; error messages print workStore.route. The work unit store has a type for each kind of render, from work-unit-async-storage.external.ts:
export interface RequestStore extends CommonWorkUnitStore {
readonly type: 'request'
// …
export interface PrerenderStoreModernClient
extends PrerenderStoreModernCommon,
StaticPrerenderStoreCommon {
readonly type: 'prerender-client'
// …
export interface PrerenderStoreModernServer
extends PrerenderStoreModernCommon,
StaticPrerenderStoreCommon {
readonly type: 'prerender'
// …
export interface PrerenderStoreModernRuntime
extends PrerenderStoreModernCommon {
readonly type: 'prerender-runtime'
// …
export interface PublicUseCacheStore extends CommonUseCacheStore {
readonly type: 'cache'
// …
export type WorkUnitStore =
| RequestStore
| CacheStore
| PrerenderStore
| BuildTimeGeneratorStoreLines 2, 7, 12, 16 and 19 are five of the types, and lines 21 to 25 the union of all of them (there are a few more, like private-cache for 'use cache: private'). app-render makes a store for the final prerender and runs React inside it, in app-render.tsx, lines 9598 to 9606 and 9703 to 9715:
const finalServerPrerenderStore: PrerenderStore = (prerenderStore = {
type: 'prerender',
phase: 'render',
rootParams,
fallbackRouteParams,
implicitTags,
renderSignal: finalServerRenderController.signal,
controller: finalServerReactController,
stagedRendering: finalStageController,let stream = workUnitAsyncStorage.run(
finalServerPrerenderStore,
ComponentMod.renderToReadableStream,
finalServerPayload,
clientModules,
{
filterStackFrame,
onError: (err: unknown) => {
return serverComponentsErrorHandler(err)
},
signal: finalServerReactController.signal,
}
)Line 2 of the store is type: 'prerender'. Line 7, renderSignal, is the abort signal that ends the prerender's waiting promises, and line 8 the controller that aborts React. In the second excerpt, line 1 runs React's Flight renderer, renderToReadableStream (line 3), inside that store, so every Server Component, and every cookies() they call, sees it. The HTML render runs in a second store, prerender-client. At next start, the same components run inside a request store, and a 'use cache' function's body runs inside a cache store of its own.
The recording found one more. The response to Asha's visit carries, inside its payload, a second payload: the page as a prefetch with her cookies would see it, which the router can keep (The RSC payload found this embedded stream). Its greeting row reads:
1a:["$","p",null,{"children":["Welcome back, ","Asha",". (store: ","prerender-runtime",")"]}]prerender-runtime: a render that has the visitor's cookies, but not the rest of the request. That's the store behind runtime prefetching, which Instant navigations showed from the app's side.
| The store's type | What renders in it | What cookies() does |
|---|---|---|
prerender | The build's render of the RSC payload, for a page or its shell. | Returns a promise that stays pending until the render aborts, then rejects: a hole if a Suspense is above it. |
prerender-client | The build's HTML render, which runs Client Components. | Throws an internal error: cookies() can't be imported into a Client Component. |
prerender-runtime | A runtime prefetch: a render with this visitor's cookies, but not the rest of the request. | Waits for the session-data stage, then returns the cookies. |
request | A render for a real request, at next start. | Returns the request's cookies. |
cache | A 'use cache' function's body. | Throws: a cached result can't depend on one visitor. |
How cookies() knows it's a prerender
cookies(), headers() and connection() each start by reading both stores. cookies(), shortened from request/cookies.ts, lines 36 to 108:
export function cookies(): Promise<ReadonlyRequestCookies> {
const callingExpression = 'cookies'
const workStore = workAsyncStorage.getStore()
const workUnitStore = workUnitAsyncStorage.getStore()
// …
if (workUnitStore) {
switch (workUnitStore.type) {
case 'cache':
const error = createCookiesInUseCacheError(workStore.route)
Error.captureStackTrace(error, cookies)
applyOwnerStack(error)
workStore.invalidDynamicUsageError ??= error
throw error
// …
case 'prerender':
return makeHangingCookies(workStore, workUnitStore)
// …
case 'prerender-runtime': {
const { stagedRendering } = workUnitStore
if (stagedRendering) {
return stagedRendering.delayUntilStage(
// …
case 'request':
trackDynamicDataInDynamicRender(workUnitStore)- Lines 3 and 4 read the work store and the work unit store.
- Line 7 switches on the store's
type. Lines 8 to 13: inside'use cache', it throws: a cached result must not depend on one visitor's cookies. - Lines 15 and 16: during a prerender, it returns
makeHangingCookies. - Lines 18 to 21: during a runtime prefetch, it waits for the session-data stage, then gives the cookies.
- Lines 23 and 24: for a real request, it marks the render as dynamic and returns the request's cookies.
makeHangingCookies, from cookies.ts, lines 159 to 177:
function makeHangingCookies(
workStore: WorkStore,
prerenderStore: PrerenderStoreModern
): Promise<ReadonlyRequestCookies> {
const cachedPromise = CachedCookies.get(prerenderStore)
if (cachedPromise) {
return cachedPromise
}
const promise = makeSessionDataHangingPromise<ReadonlyRequestCookies>(
prerenderStore.renderSignal,
workStore.route,
'`cookies()`',
prerenderStore
)
CachedCookies.set(prerenderStore, promise)
return promise
}Lines 5 to 8 reuse one promise per store, so every cookies() call in a prerender gets the same one. Lines 10 to 15 make it with makeSessionDataHangingPromise, tied to the store's renderSignal (line 11). "Session data" means data a runtime prefetch can have: cookies and headers. headers() works the same way (headers.ts, lines 98 and 99). connection() is stricter: it hangs in every kind of prerender, runtime prefetches included, from request/connection.ts, lines 79 to 88:
case 'prerender':
case 'prerender-client':
case 'prerender-runtime':
// We return a promise that never resolves to allow the prerender to
// stall at this point.
return makeDynamicHangingPromise(
workUnitStore.renderSignal,
workStore.route,
'`connection()`'
)Lines 1 to 3 cover all three prerender types, and line 6 returns a promise that never resolves, as the comment on lines 4 and 5 says; it only rejects, when the render aborts. Pick each version of the page below. It's the same cookies() call each time; only what's around it changes:
app/teas/today/page.tsximport { Suspense } from "react";import { cookies } from "next/headers";import { getPrice } from "@/lib/get-price";import { storeType } from "@/lib/store-type"; async function Greeting() { const name = (await cookies()).get("name")?.value ?? "guest"; return <p>Welcome back, {name}. (store: {storeType()})</p>;} export default async function TodayPage() { const price = await getPrice(); return ( <main> <h1>Tea of the day: Green tea</h1> <p>{price}</p> <p>Shell rendered in store: {storeType()}</p> <Suspense fallback={<p>Finding your name…</p>}> <Greeting /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...getPrice ran✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ◐ /teas/today 15m 1y ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.2 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.8 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas/today.html 1.3 kB.next/server/app/teas/today.meta 4.1 kB.next/server/app/teas/today.segments/_full.segment.rsc 4.0 kB.next/server/app/teas/today.segments/_tree.segment.rsc 205 bytes.next/server/app/teas/today.segments/teas/today/__PAGE__.segment.rsc 3.2 kB $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyDuring the build, cookies() sees a prerender store and returns a promise that stays pending until the render aborts, then rejects. The build doesn't wait for it, leaves Greeting as a hole with its fallback, and lists ◐ /teas/today.
Inside Suspense, the build makes a shell. Without it, the build fails, because nothing can stand in for Greeting in the shell. Inside 'use cache', the build fails at once, with lines 8 to 13's error: cookies() can't be read inside "use cache". Request-time APIs taught these rules from the app's side; this is the code that enforces them.
Stopping the prerender: abort signals and hanging promises
A promise that stays pending would keep any normal program waiting forever. A prerender doesn't wait: it ends itself with an abort signal, the browser and Node.js tool for cancelling work (AbortController makes one, and controller.abort() fires it). The hanging promise listens to it, from dynamic-rendering-utils.ts, lines 605 to 637:
function makeHangingPromiseWithError<T>(
signal: AbortSignal,
error: Error
): Promise<T> {
if (signal.aborted) {
return Promise.reject(error)
} else {
const hangingPromise = new Promise<T>((_, reject) => {
const boundRejection = reject.bind(null, error)
let currentListeners = abortListenersBySignal.get(signal)
if (currentListeners) {
currentListeners.push(boundRejection)
} else {
const listeners = [boundRejection]
abortListenersBySignal.set(signal, listeners)
signal.addEventListener(
'abort',
() => {
for (let i = 0; i < listeners.length; i++) {
listeners[i]()
}
},
{ once: true }
)
}
})
// We are fine if no one actually awaits this promise. We shouldn't consider this an unhandled rejection so
// we attach a noop catch handler here to suppress this warning. If you actually await somewhere or construct
// your own promise out of it you'll need to ensure you handle the error when it rejects.
hangingPromise.catch(ignoreReject)
return hangingPromise
}
}- Lines 5 and 6: if the render is already over, reject at once.
- Line 8: a promise whose executor never calls
resolve. Line 9 prepares itsreject, with the error. - Lines 10 to 25 keep one list of rejections per signal, and one
abortlistener that calls them all (lines 19 to 21). - Line 30 adds an empty
catch, so a promise nobody awaited doesn't show as an unhandled rejection.
The error it rejects with explains itself, from lines 29 to 40:
class HangingPromiseRejectionError extends Error {
public readonly digest = HANGING_PROMISE_REJECTION
constructor(
public readonly route: string,
public readonly expression: string
) {
super(
`During prerendering, ${expression} rejects when the prerender is complete. Typically these errors are handled by React but if you move ${expression} to a different context by using \`setTimeout\`, \`after\`, or similar functions you may observe this error and you should handle it in that context. This occurred at route "${route}".`
)
}
}Line 9: "During prerendering, cookies() rejects when the prerender is complete." When does the prerender decide it's complete? prerenderToStream in app-render.tsx renders twice, as its comment says, in lines 9138 to 9150:
/**
* cacheComponents with PPR
*
* The general approach is to render the RSC stream first allowing any cache reads to resolve.
* Once we have settled all cache reads we restart the render and abort after a single Task.
*
* Unlike with the non PPR case we can't synchronously abort the render when a dynamic API is used
* during the initial render because we need to ensure all caches can be filled as part of the initial Task
* and a synchronous abort might prevent us from filling all caches.
*
* Once the render is complete we allow the SSR render to finish and use a combination of the postponed state
* and the reactServerIsDynamic value to determine how to treat the resulting render
*/- Line 4: the first render, the prospective one, lets every cache read finish.
getPriceruns here and fills its entry. - Line 5: then Next.js renders again, and aborts that render after a single task.
- Lines 7 to 9: it can't abort the first render early, or a cache might be cut off while it fills.
The prospective render ends when the caches are ready, from lines 9357 to 9370:
initialServerReactController.signal.addEventListener(
'abort',
() => {
initialServerRenderController.abort()
initialServerPrerenderController.abort()
},
{ once: true }
)
// Wait for all caches to be finished filling and for async imports to resolve
trackPendingModules(cacheSignal)
await cacheSignal.cacheReady()
initialServerReactController.abort()Line 12 waits for cacheSignal.cacheReady(); line 14 aborts React, and lines 1 to 8 pass the abort on to the render signal the hanging promises listen to. The recorded build printed getPrice ran once:
getPrice ran
✓ Generating static pages (3/3)The final render reads getPrice from the cache the first one filled. It runs as a list of tasks, with runInSequentialTasks, from app-render-render-utils.ts, lines 11 to 21:
/**
* This is a utility function to make scheduling sequential tasks that run back to back easier.
* We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between.
*
* The first function runs in the first task. Each subsequent function runs in its own task.
* The returned promise resolves after the last task completes.
*/
export function runInSequentialTasks<R>(
first: () => R,
...rest: Array<() => void>
): Promise<Awaited<R>> {Lines 5 and 6: the first function runs in one task (a setTimeout callback), each next one in its own task, back to back. Anything that can finish in the first task, like cached data and synchronous rendering, ends up in the shell. Anything waiting on a hanging promise can't, and the HTML render's last task aborts it, from app-render.tsx, lines 9884 to 9972:
let { prelude: unprocessedPrelude, postponed } =
await runInSequentialTasks(
() => {
// …
onError: (err: unknown, errorInfo: ErrorInfo) => {
if (
isPrerenderInterruptedError(err) ||
finalClientReactController.signal.aborted
) {
// …
trackAllowedDynamicAccess(
// …
},
() => {
workUnitAsyncStorage.run(
finalClientPrerenderStore,
finalClientReactController.abort.bind(finalClientReactController)
)
}
)Line 1 receives two results: the prelude (the HTML React could finish, the shell) and postponed (where it stopped). Lines 5 to 11: every part that was still waiting when the abort came reaches onError, and Next.js checks it with trackAllowedDynamicAccess. Lines 14 to 19 are the last task: abort.
Suspense above the waiting part turns it into a hole; without one, there's no shell to save.Where the uncached data error comes from
Without Suspense around Greeting, the recorded build stopped with this error, followed by its component stack:
Error: Route "/teas/today": Next.js encountered uncached or runtime data during prerendering.
at main (<anonymous>)
at body (<anonymous>)
at html (<anonymous>)Next.js 16.0 to 16.2 printed this error as Uncached data was accessed outside of <Suspense>. In 16.4.3 the messages live in blocking-route-messages.ts, one for each kind of data. The one the build printed, lines 85 to 95:
export function createDynamicOrRuntimeBodyError(route: string): Error {
return new Error(
`Route "${route}": Next.js encountered uncached or runtime data during prerendering.\n\n` +
`\`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`<Suspense>\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.\n\n` +
`Ways to fix this:\n` +
` - [stream] Provide a placeholder with \`<Suspense fallback={...}>\` around the data access\n` +
` - [cache] For uncached data (\`fetch\`, database calls): cache the access with \`"use cache"\` (does not apply to \`connection()\`)\n` +
` - [block] Set \`export const instant = false\` to allow a blocking route\n\n` +
`Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic`
)
}Line 3 is the first line of the error, and lines 5 to 8 its ways to fix it. Who decides to throw it? Each part that was still waiting at the abort comes with React's component stack: the components it was inside, innermost first, like at main, at body, at html in the error above. Next.js searches that text, in dynamic-rendering-validation.ts, lines 290 to 345:
export function trackAllowedDynamicAccess(
dynamicReason: unknown,
workStore: WorkStore,
componentStack: string,
dynamicValidation: DynamicValidationState,
clientDynamic: DynamicTrackingState
) {
// …
} else if (hasSuspenseRegex.test(componentStack)) {
// this error had a Suspense boundary above it so we don't need to report it as a source
// of disallowed
dynamicValidation.hasAllowedDynamic = true
return
// …
const error = addErrorContext(
createBodyError(dynamicHoleKind, workStore.route),
componentStack,
null
)
dynamicValidation.dynamicErrors.push(error)
return
}Line 9: if the stack has a Suspense, the waiting part is allowed: it will be a hole. Otherwise, lines 15 to 20 record the error above. The search is one regular expression, from line 87:
const hasSuspenseRegex = /\n\s+at Suspense \(<anonymous>\)/A line that says at Suspense (<anonymous>). The recorded stack has main, body and html, and no Suspense. (A loading.tsx counts too: it renders a Suspense.) After the render, the errors are thrown, from lines 873 to 885:
if (prelude !== PreludeState.Full) {
// We didn't have any sync bailouts but there may be user code which
// blocked the root. We would have captured these during the prerender
// and can log them here and then terminate the build/validating render
const dynamicErrors = dynamicValidation.dynamicErrors
if (dynamicErrors.length > 0) {
for (let i = 0; i < dynamicErrors.length; i++) {
logDisallowedDynamicError(workStore, dynamicErrors[i])
}
throw new StaticGenBailoutError()
}
Line 1: only when the shell isn't complete. Lines 7 to 9 print each recorded error, the message you saw, and line 11 stops this page's prerender with StaticGenBailoutError, which fails the build. So the error isn't about speed. Cached work, even slow cached work, runs during the prerender and finishes. The error means a part was still waiting on uncached or runtime work (an uncached fetch, connection(), cookies()) when the prerender aborted, and no Suspense above it could stand in for it in the shell.
The postponed state in the .meta file
When the shell is complete but parts are still waiting, React's prerender returns them as postponed: a plain object saying where it stopped. Next.js serializes it, in app-render.tsx, lines 10058 to 10069:
if (postponed != null) {
metadata.postponed = await getDynamicHTMLPostponedState(
postponed,
preludeIsEmpty
? DynamicHTMLPreludeState.Empty
: DynamicHTMLPreludeState.Full,
fallbackRouteParams,
resumeDataCache,
cacheComponents,
renderOpts.experimental.maxPostponedStateSizeBytes,
renderOpts.experimental.disableResumeDataCacheCompression
)Line 3 is React's object, lines 4 to 6 whether the shell had any HTML, and line 8 the resume data cache: every 'use cache' entry the prerender filled. serializePostponedState writes them as one string, from postponed-state.ts, lines 88 to 118:
async function serializePostponedState(
postponedString: string,
resumeDataCache: PrerenderResumeDataCache | RenderResumeDataCache,
isCacheComponentsEnabled: boolean,
maxPostponedStateSizeBytes: number | undefined,
disableResumeDataCacheCompression: boolean
): Promise<string> {
const prefix = `${postponedString.length}:${postponedString}`
let serializedResumeDataCache = await stringifyResumeDataCache(
resumeDataCache,
isCacheComponentsEnabled
)
// …
serializedResumeDataCache = deflateResumeDataCache(
serializedResumeDataCache
)
}
return prefix + serializedResumeDataCacheLine 8: the length of React's part, a colon, then the part. Lines 9 to 12 turn the cache into JSON, lines 14 to 16 compress it, and line 19 joins the two. The build writes the result into the page's .meta file, beside its shell. The recorder kept .next/server/app/teas/today.meta, each long value cut short here:
{
"status": 200,
"headers": {
"x-nextjs-stale-time": "300",
"x-nextjs-prerender": "1",
"x-next-cache-tags": "_N_T_/layout,_N_T_/teas/layout,_N_T_/teas/today/layout,_N_T_/teas/today/page,_N_T_/teas/today"
},
"postponed": "2508:[1,{\"nextSegmentId\":1,\"rootFormatContext\":{\"insertionM…",
"segmentPaths": "[\"/_tree\",\"/_full\",\"/teas/today/__PAGE__\"]",
"prefetchHints": "{\"hints\":64,\"slots\":{\"children\":{\"hints\":96,\"slots\":{\"child…",
"routeCache": "{\"key\":\"/route-cache/APP_PAGE/4e2b092f819603e8a7861c29ac8ac…"
}postponed starts with 2508:. The Terminal tab's list of prerendered files marks teas/today.meta as holding postponed state. Partial Prerendering found that mark; take the value apart below:
2508:React's part is 2,508 characters long. Reading that many characters after the colon gives it; what's left is the cache.
serializePostponedState writes the length of React's part, a colon, React's part, then the resume data cache. The length says where React's part ends, so the rest can be read as the cache.The cache part is the reason getPrice never ran at next start: its result travels with the page, as Flight rows, "0:\"$@1\"\n1:\"$$4 a box (store: cache)\"\n". The $$ is an escaped $, as The RSC payload read. How 'use cache' builds its keys and stores entries is for Caching under the hood, in the next module.
The store types, the .meta file's fields, the x-nextjs-postponed header, the postponed state's format and the function names are Next.js 16.4.3's internals, and the probe reads one of them. They can change in any release. The documented behavior is what stays: a static shell made ahead, request-time content in holes behind Suspense, and one response.
Resuming on request
At next start, a request for /teas/today finds the cached page: its shell, and its postponed state. The page's handler sends the shell, then resumes, from app-page-runtime.ts, lines 2413 to 2425:
resumeRender(body, cachedData.postponed, span)
return sendRenderResult({
req,
res,
generateEtags: nextConfig.generateEtags,
poweredByHeader: nextConfig.poweredByHeader,
result: body,
// We don't want to cache the response if it has postponed data because
// the response being sent to the client it's dynamic parts are streamed
// to the client on the same request.
cacheControl: { revalidate: 0, expire: undefined },
})Line 1 starts the resume with the saved postponed, and line 3 sends body, which starts with the shell. Lines 9 to 12: the answer must not be cached, because its holes are this visitor's. resumeRender, from lines 1753 to 1799:
const resumeRender = (
body: RenderResult,
postponed: string | undefined,
span?: Span
) => {
// Attach the continuation before rendering so the prelude can start
// streaming immediately.
const transformer = new TransformStream<Uint8Array, Uint8Array>()
body.push(transformer.readable)
// …
// Start the resume without awaiting it. The response consumes the
// continuation stream attached above.
doRender({
span,
postponed,
// The resume retains concrete param values; staging only defers
// access.
fallbackRouteParams: null,
renderOperation: 'render',
})
// …
// Pipe the resume result to the transformer.
await result.value.html.pipeTo(transformer.writable)Lines 8 and 9 put an empty stream behind the shell, so the response can start at once. Lines 13 to 20 render the page with postponed, and line 23 pipes the result into that stream. Inside app-render, on Node.js, the HTML side resumes React, from app-render.tsx, lines 4157 to 4192:
} else if (postponedState) {
// We assume we have dynamic HTML requiring a resume render to complete
const { postponed, preludeState } =
getPostponedFromState(postponedState)
// …
const { stream: htmlStream } = await workUnitAsyncStorage.run(
requestStore,
resumeToFizzStream,
resumeAppElement,
postponed,
{
onError: htmlRendererErrorHandler,
onAllReady: finishHtmlRender,
nonce,
}
)Lines 3 and 4 take React's part out of the postponed state. Lines 6 to 10 call resumeToFizzStream inside the request's store, with it: React skips everything already in the shell and renders only the holes, now with a request store, so cookies() gives Asha's name. The recorded response, part by part:
1290 bytes of .next/server/app/teas/today.html: the shell
<script>(self.__next_f=self.__next_f||[]).push([0])</script>
<script>self.__next_f.push([1,"…"])</script> 37 rows: 1, 2, 3 … 1c, 1a, 19
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" noModule=""></script>
<div hidden id="S:0"><p>Welcome back, <!-- -->Asha<!-- -->. (store: <!-- -->request<!-- -->)</p></div>
<script>$RB=[];$RV=function(a){…};$RC("B:0","S:0")</script>
<script>self.__next_f.push([1,"…"])</script> 11 rows: 1b, 1b, 1b … 1b, 1b, 1b
</body></html>- Line 1: the first 1290 bytes are
.next/server/app/teas/today.html, byte for byte. Both visits began with the same file. - Lines 2 and 3: the payload, rendered for this request.
- Lines 5 and 6: the hole's HTML, rendered by the resume, and the script that swaps it in.
The response came with x-nextjs-postponed: 1 (an internal header of Next.js 16.4.3, which app code must not rely on) and Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. One more thing the probe shows: the payload isn't resumed, it's rendered again for the request. Its row for the page's <main> says:
a:["$","main",null,{"children":[["$","h1",null,{"children":"Tea of the day: Green tea"}],["$","p",null,{"children":"$$4 a box (store: cache)"}],["$","p",null,{"children":["Shell rendered in store: ","request"]}],["$","$c",null,{"fallback":["$","p",null,{"children":"Finding your name…"}],"children":"$L1c"}]]}]Shell rendered in store: request, while the shell's HTML says prerender. The shell's HTML is the build's; the payload, which the browser's React hydrates from, is this request's, made with the cached getPrice from the resume data cache. In a real page, the two agree, because everything in the shell comes from code that gives the same result every time; the probe is the one thing that doesn't, which is why it's only a probe.
next start, the shell is sent as the build wrote it. React resumes from the postponed state and renders only what was left, the hole; Next.js renders the payload again for the request, using the cached getPrice.Debugging a prerender
When a page doesn't prerender the way you expect, these help, in a Next.js 16.4.3 project:
next build --debug-prerender, as the error suggests, for readable stack traces that point at your component instead ofat main.next dev, then open the page: the dev overlay shows the same error with your file and line.- The build's route table:
◐means a shell with holes, and.next/server/app/<page>.metaholds apostponedfield. - The response's headers: in Next.js 16.4.3,
x-nextjs-postponed: 1means the server resumed the page after its shell. It's an internal header: read it to debug, and never make app code depend on it.
Sort what each call does, given the store it runs in:
cookies()in aprerenderstoreconnection()in aprerenderstoreconnection()in aprerender-runtimestorecookies()in acachestorecookies()in arequeststorecookies()in aprerender-runtimestore
Look at the store's type: a prerender of any kind, a cache, or a real request.
Common mix-ups
- "cookies() returns empty cookies during the build." No. It returns a promise that stays pending until the render aborts, then rejects; the part that awaits it can't finish, so it becomes a hole.
- "The build waits for a timeout, then gives up on slow data." No. It renders once to fill the caches, then renders again and aborts after its tasks. Cached work finishes, however slow; uncached or runtime work that suspends can't.
- "The uncached data error means the data is too slow." No. It means uncached or runtime work suspended with no
Suspenseabove it. Speed doesn't matter: cached data, even slow, finishes during the prerender. - "The postponed state holds the visitor's data." No. It holds where React stopped and the build's cache entries; the build never had a visitor.
- "At next start, the whole page is rendered again." Not the HTML: the shell is sent as built and React renders only the holes. The payload is rendered again, with the cached data.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints. Some ask you to find a line in the source, and the excerpts above hold it.
This is the page before the hole arrived. Which store did the shell render in?
Tea of the day: Green tea
$4 a box (store: cache)
Shell rendered in store: prerender
Finding your name…prerender: the shell was rendered during next build, in the store app-render made for the final prerender.
getPrice logs getPrice ran. Counting the build and both visits at next start, how many times did it run?
getPrice ran
✓ Generating static pages (3/3)Once, during the build's prospective render. The final render and both visits read the cached entry; the resume data cache carried it to next start.
Which function, in the end, makes the promise cookies() returns during a prerender, and ties it to the abort signal?
function makeHangingCookies(
workStore: WorkStore,
prerenderStore: PrerenderStoreModern
): Promise<ReadonlyRequestCookies> {
const cachedPromise = CachedCookies.get(prerenderStore)
if (cachedPromise) {
return cachedPromise
}
const promise = makeSessionDataHangingPromise<ReadonlyRequestCookies>(
prerenderStore.renderSignal,
workStore.route,
'`cookies()`',
prerenderStore
)
CachedCookies.set(prerenderStore, promise)
return promise
}makeHangingPromiseWithError, in dynamic-rendering-utils.ts, lines 605 to 637. It adds the promise's rejection to the render signal's abort listener.
How many characters long is React's part of this postponed state?
2508:[1,{"nextSegmentId":1,"rootFormatContext…2,508 characters. parsePostponedState reads that many characters after the colon as React's part, and treats the rest as the resume data cache.
This is the stack of the part that stopped the build. What does Next.js look for in it to allow a hole?
at main (<anonymous>)
at body (<anonymous>)
at html (<anonymous>)A line at Suspense (<anonymous>). With one, trackAllowedDynamicAccess marks the part as an allowed hole; this stack has none, so the build failed.
The resumed response holds one visitor's greeting. What Cache-Control header did the recorded response get?
resumeRender(body, cachedData.postponed, span)
return sendRenderResult({
req,
res,
generateEtags: nextConfig.generateEtags,
poweredByHeader: nextConfig.poweredByHeader,
result: body,
// We don't want to cache the response if it has postponed data because
// the response being sent to the client it's dynamic parts are streamed
// to the client on the same request.
cacheControl: { revalidate: 0, expire: undefined },
})private, no-cache, no-store, max-age=0, must-revalidate. The response holds Asha's greeting, so no browser or shared cache may keep it.
Check your understanding
For each question, ask which store the code runs in, and what happens when the prerender aborts.
Question 1 of 6How does
cookies()know whether it runs in a prerender?Choose an answer to see the explanation.
Question 2 of 6What does a hanging promise do when the prerender is over?
Choose an answer to see the explanation.
Question 3 of 6Why does Next.js render the RSC payload once before the final prerender?
Choose an answer to see the explanation.
Question 4 of 6With
Greetingoutside anySuspense, why does the build fail instead of making a shell?Choose an answer to see the explanation.
Question 5 of 6What is in the
postponedfield ofteas/today.meta?Choose an answer to see the explanation.
Question 6 of 6At
next start, what does Next.js send first for/teas/today?Choose an answer to see the explanation.
Key takeaways
- Every render runs inside a work unit store (
workUnitAsyncStorage) whosetypeisprerender,prerender-client,prerender-runtime,requestorcache, among others. - During a prerender,
cookies(),headers()andconnection()return promises that stay pending until the render's abort signal fires, then reject. - The build renders once to fill caches, then again, and aborts after its tasks. A waiting part under
Suspensebecomes a hole; one without fails the build. - The
.metafile'spostponedholds React's state and the resume data cache, with every'use cache'entry the prerender filled. - At
next start, the shell goes out as built, React resumes only the holes, and the payload is rendered again with the cached data.
A prerender is a render whose store says "prerender": request-time code hangs, the render aborts, and the waiting parts become holes.
The postponed state lets next start finish them for each visitor.
Coming next: The client router. It reads the router state tree, soft and hard navigations, the segment cache, and how Link prefetches what's on screen.