cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Name 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.
  • 02
    Explain 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.
  • 03
    Read 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:

The Tea of the day page, with a probeNext.js
app/teas/today/lib/
app/teas/today/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3
localhost:3000/teas/today
Tea of the day: Green tea

$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:

The page after Asha's visit, as recordedText
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:

lib/store-type.ts: a probe for this lesson onlyTypeScript
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.

Left, next build: the page's shell renders in a store of type prerender, getPrice's body in a store of type cache, and the HTML in a store of type prerender-client. Right, next start: Greeting renders in a store of type request, and the embedded prefetch in a store of type prerender-runtime.next buildprerender: the shellcache: getPriceprerender-client: HTMLnext startrequest: Greetingprerender-runtimethe embedded prefetchworkUnitAsyncStorage.getStore().type
The same components, run at different times. Each render runs inside a work unit store, and its type is what cookies(), connection() and the probe read.
Real-life analogyA cook who checks the order slip

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
The main idea

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. Its type says what kind of render it is.

The work store's type starts like this, in work-async-storage.external.ts, lines 18 to 28:

WorkStore: work-async-storage.external.ts, lines 18 to 28TypeScript
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: string

Line 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:

The work unit stores' types, shortened from work-unit-async-storage.external.tsTypeScript
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
  | BuildTimeGeneratorStore

Lines 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:

The final prerender's store: app-render.tsx, lines 9598 to 9606TypeScript
const finalServerPrerenderStore: PrerenderStore = (prerenderStore = {
  type: 'prerender',
  phase: 'render',
  rootParams,
  fallbackRouteParams,
  implicitTags,
  renderSignal: finalServerRenderController.signal,
  controller: finalServerReactController,
  stagedRendering: finalStageController,
Rendering inside it: app-render.tsx, lines 9703 to 9715TypeScript
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:

The greeting in the embedded prefetch payload (recorded)Text
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 work unit stores, and what cookies() does in each
The store's typeWhat renders in itWhat cookies() does
prerenderThe 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-clientThe build's HTML render, which runs Client Components.Throws an internal error: cookies() can't be imported into a Client Component.
prerender-runtimeA 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.
requestA render for a real request, at next start.Returns the request's cookies.
cacheA '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:

cookies(), shortened from request/cookies.tsTypeScript
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)
  1. Lines 3 and 4 read the work store and the work unit store.
  2. 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.
  3. Lines 15 and 16: during a prerender, it returns makeHangingCookies.
  4. Lines 18 to 21: during a runtime prefetch, it waits for the session-data stage, then gives the cookies.
  5. 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:

makeHangingCookies: cookies.ts, lines 159 to 177TypeScript
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:

connection() during a prerender: connection.ts, lines 79 to 88TypeScript
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:

Playground: one cookies() call, three stores
The Tea of the day page, with this version of app/teas/today/page.tsxNext.js
app/teas/today/lib/
app/teas/today/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3
$ 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✓ Ready
Try it yourself
Inside Suspense

During 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.

Every version was built with next build from Next.js 16.4.3. The Terminal shows what the build printed. Reset goes back to the first version.

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:

makeHangingPromiseWithError: dynamic-rendering-utils.ts, lines 605 to 637TypeScript
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
  }
}
  1. Lines 5 and 6: if the render is already over, reject at once.
  2. Line 8: a promise whose executor never calls resolve. Line 9 prepares its reject, with the error.
  3. Lines 10 to 25 keep one list of rejections per signal, and one abort listener that calls them all (lines 19 to 21).
  4. 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:

HangingPromiseRejectionError: dynamic-rendering-utils.ts, lines 29 to 40TypeScript
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:

The plan: app-render.tsx, lines 9138 to 9150TypeScript
/**
 * 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
 */
  1. Line 4: the first render, the prospective one, lets every cache read finish. getPrice runs here and fills its entry.
  2. Line 5: then Next.js renders again, and aborts that render after a single task.
  3. 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:

Waiting for the caches: app-render.tsx, lines 9357 to 9370TypeScript
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:

What the build printed around the prerender (recorded)Text
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:

runInSequentialTasks: app-render-render-utils.ts, lines 11 to 21TypeScript
/**
 * 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:

The final HTML prerender, shortened from app-render.tsxTypeScript
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.

Task 1: the final prerender renders; cookies returns a promise that stays pending. Task 2: Next.js aborts, the render signal fires, and the promise rejects. If a Suspense boundary is above Greeting, it becomes a hole with its fallback in the shell. If not, the build fails.task 1: rendercookies(): pendingtask 2: abort()the promise rejectsSuspense above: a holeno Suspense: build error◐, fallback in the shellthe route can't prerender
The final prerender gets one task to render. In the next task Next.js aborts it, and every hanging promise rejects at once. A 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:

The build's error without Suspense (recorded)Text
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:

createDynamicOrRuntimeBodyError: blocking-route-messages.ts, lines 85 to 95TypeScript
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:

trackAllowedDynamicAccess, shortened from dynamic-rendering-validation.tsTypeScript
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:

hasSuspenseRegex: dynamic-rendering-validation.ts, line 87TypeScript
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:

throwIfDisallowedDynamic, from dynamic-rendering-validation.ts, lines 873 to 885TypeScript
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:

Saving the postponed state: app-render.tsx, lines 10058 to 10069TypeScript
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:

serializePostponedState, shortened from postponed-state.tsTypeScript
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 + serializedResumeDataCache

Line 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:

.next/server/app/teas/today.meta, laid out (recorded)JSON
{
  "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:

Playground: take the postponed state apart
The characters before the first colonText
2508:
Try it yourself
The length

React's part is 2,508 characters long. Reading that many characters after the colon gives it; what's left is the cache.

Every part is from the postponed field of .next/server/app/teas/today.meta, written by next build from Next.js 16.4.3; long parts are cut short. The cache's text is what the recorded part inflates to. Reset goes back to the length.
The postponed field of teas/today.meta in three parts: the length 2508 and a colon; React's postponed state, a JSON list that starts with 1 and holds where React stopped; and the resume data cache, deflated and written in base64, with getPrice's result.2508:[1,{…where React stopped…}]eJy… (base64)lengthReact's postponed stateresume data cachegetPrice: $4 a box2,508 characters of JSON
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.

Internals, from this build

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:

Sending the shell, then resuming: app-page-runtime.ts, lines 2413 to 2425TypeScript
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:

resumeRender, shortened from app-page-runtime.tsTypeScript
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:

Resuming React's HTML render, shortened from app-render.tsxTypeScript
} 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:

Asha's response, part by part (recorded)HTML
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>
  1. Line 1: the first 1290 bytes are .next/server/app/teas/today.html, byte for byte. Both visits began with the same file.
  2. Lines 2 and 3: the payload, rendered for this request.
  3. 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:

The page's row in the payload Asha got (recorded)Text
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.

Left, two files the build wrote: teas/today.html, the shell, and teas/today.meta, with the postponed state. The shell goes straight into the response. The postponed state goes to React's resume, which renders Greeting for this request; its HTML and the payload follow the shell in the same response..next/serverteas/today.htmlteas/today.metaresumeresponsethe shellhole + payloadone response, x-nextjs-postponed: 1
At 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 of at 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>.meta holds a postponed field.
  • The response's headers: in Next.js 16.4.3, x-nextjs-postponed: 1 means 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:

What does the call do here?
  • cookies() in a prerender store
  • connection() in a prerender store
  • connection() in a prerender-runtime store
  • cookies() in a cache store
  • cookies() in a request store
  • cookies() in a prerender-runtime store
Try it yourself
0 of 6 correct

Look at the store's type: a prerender of any kind, a cache, or a real request.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 Suspense above 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.

Exercise 1 · Warm-upRead the probe

This is the page before the hole arrived. Which store did the shell render in?

Starter codeText
Tea of the day: Green tea
$4 a box (store: cache)
Shell rendered in store: prerender
Finding your name…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upHow many times did getPrice run?

    getPrice logs getPrice ran. Counting the build and both visits at next start, how many times did it run?

    Starter codeText
    getPrice ran
    ✓ Generating static pages (3/3)

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind it in the source: the hanging promise

      Which function, in the end, makes the promise cookies() returns during a prerender, and ties it to the abort signal?

      Starter codeTypeScript
      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
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead the postponed state's length

        How many characters long is React's part of this postponed state?

        Starter codeText
        2508:[1,{"nextSegmentId":1,"rootFormatContext…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWhat makes a waiting part allowed?

          This is the stack of the part that stopped the build. What does Next.js look for in it to allow a hole?

          Starter codeText
              at main (<anonymous>)
              at body (<anonymous>)
              at html (<anonymous>)

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhy can't the answer be cached?

            The resumed response holds one visitor's greeting. What Cache-Control header did the recorded response get?

            Starter codeTypeScript
            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 },
            })

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which store the code runs in, and what happens when the prerender aborts.

              The prerendering quiz · 6 questionsScore: first tries count
              1. Question 1 of 6How does cookies() know whether it runs in a prerender?

                Choose an answer to see the explanation.

              2. Question 2 of 6What does a hanging promise do when the prerender is over?

                Choose an answer to see the explanation.

              3. Question 3 of 6Why does Next.js render the RSC payload once before the final prerender?

                Choose an answer to see the explanation.

              4. Question 4 of 6With Greeting outside any Suspense, why does the build fail instead of making a shell?

                Choose an answer to see the explanation.

              5. Question 5 of 6What is in the postponed field of teas/today.meta?

                Choose an answer to see the explanation.

              6. 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) whose type is prerender, prerender-client, prerender-runtime, request or cache, among others.
              • During a prerender, cookies(), headers() and connection() 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 Suspense becomes a hole; one without fails the build.
              • The .meta file's postponed holds 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.

              CompleteFrontend Clear concepts. Working examples.