Architecture Overview
Server Runtime
Rendering Pipeline
Client Navigation
Caching and Export
Development Tools
Build and Configuration
Ecosystem Packages
Testing Infrastructure
How It Works
The following files were used as context for generating this wiki page:
Async Request Context serves as the underlying execution tracking and scoping mechanism in Next.js, managing asynchronous data flow across server rendering passes, server actions, route handlers, and background tasks. By leveraging Node.js AsyncLocalStorage alongside custom store implementations, it solves the problem of safely propagating request headers, cookies, render phases, and cache configurations down deeply nested component trees without relying on global mutable state or explicit prop drilling.
Key design decisions separate global work parameters from per-request metadata and cached execution scopes, preventing state leaks between independent cache boundaries and prerender passes. It interacts closely with dynamic APIs such as headers() and cookies() to enforce phase-based mutation rules, track dynamic data access, and schedule deferred background execution via after(). Sources: packages/next/src/server/app-render/work-async-storage.external.ts:17-151, packages/next/src/server/use-cache/use-cache-wrapper.ts:620-626, packages/next/src/server/async-storage/request-store.ts:99-123, packages/next/src/server/request/headers.ts:42-56, packages/next/src/server/request/cookies.ts:35-50, packages/next/src/server/after/after-context.ts:39-53, packages/next/src/server/web/adapter.ts:339-346
Next.js builds its asynchronous context mechanism on top of an abstraction layer that wraps Node.js AsyncLocalStorage. When AsyncLocalStorage is unavailable in a runtime environment, the infrastructure falls back to a FakeAsyncLocalStorage implementation that throws errors on store operations like run(), disable(), exit(), and enterWith(). The base factory function createAsyncLocalStorage() inspects globalThis.AsyncLocalStorage to instantiate either the native instance or the fallback variant.
const maybeGlobalAsyncLocalStorage =
typeof globalThis !== 'undefined' && (globalThis as any).AsyncLocalStorage
export function createAsyncLocalStorage<
Store extends {},
>(): AsyncLocalStorage<Store> {
if (maybeGlobalAsyncLocalStorage) {
return new maybeGlobalAsyncLocalStorage()
}
return new FakeAsyncLocalStorage()
}The application rendering engine divides execution contexts across distinct store types. Each store instance wraps AsyncLocalStorage with a specialized interface defining its contextual properties.
Sources: packages/next/src/server/app-render/action-async-storage.external.ts:5-10, packages/next/src/server/app-render/console-async-storage.external.ts:6-15, packages/next/src/server/app-render/dynamic-access-async-storage.external.ts:6-10, packages/next/src/server/app-render/action-async-storage-instance.ts:4-6, packages/next/src/server/app-render/console-async-storage-instance.ts:4-6, packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts:4-6
Note
ConsoleStore utilizes the dim property to control output coloring. When dim is set to true, log colors are dimmed to indicate that the log originates from a repeat or validation render that is irrelevant to the primary server action.
To propagate execution contexts across asynchronous boundaries, helper utilities manage function binding and snapshot creation. bindSnapshot delegates to native AsyncLocalStorage.bind() or FakeAsyncLocalStorage.bind(), while createSnapshot() captures execution state or returns the identity function when native capabilities are absent. Sources: packages/next/src/server/app-render/async-local-storage.ts:48-68
export function bindSnapshot<T>(
fn: T
): T {
if (maybeGlobalAsyncLocalStorage) {
return maybeGlobalAsyncLocalStorage.bind(fn)
}
return FakeAsyncLocalStorage.bind(fn)
}
export function createSnapshot(): <R, TArgs extends any[]>(
fn: (...args: TArgs) => R,
...args: TArgs
) => R {
if (maybeGlobalAsyncLocalStorage) {
return maybeGlobalAsyncLocalStorage.snapshot()
}
return function (fn: any, ...args: any[]) {
return fn(...args)
}
}Additionally, the generic WithStore type signature standardizes how storage implementations supply a context to callback functions:
export type WithStore<Store extends {}, Context extends {}> = <Result>(
storage: AsyncLocalStorage<Store>,
context: Context,
callback: (store: Store) => Result
) => ResultThe request store lifecycle centers on initializing per-request state, sanitizing incoming headers, binding request cookies, and bridging runtime adapters between Node.js request/response pairs and Edge handlers or probe workers. The RequestStore instance relies on decoupled inputs, allowing contexts to be instantiated without requiring a live Node.js IncomingMessage or BaseNextRequest. Sources: packages/next/src/server/async-storage/request-store.ts:92-98
When a request store is created for rendering via createRequestStoreForRender, it assigns a default phase of 'render', extracts headers from req.headers, and configures cookie update callbacks. The internal getHeaders helper processes raw headers using HeadersAdapter.from(headers) and strips internal plumbing headers such as FLIGHT_HEADERS, NEXT_REQUEST_ID_HEADER, and NEXT_HTML_REQUEST_ID_HEADER before sealing the headers instance. Sources: packages/next/src/server/async-storage/request-store.ts:33-49, packages/next/src/server/async-storage/request-store.ts:158-174
function getHeaders(headers: Headers | IncomingHttpHeaders): ReadonlyHeaders {
const cleaned = HeadersAdapter.from(headers)
for (const header of FLIGHT_HEADERS) {
cleaned.delete(header)
}
cleaned.delete(NEXT_REQUEST_ID_HEADER)
cleaned.delete(NEXT_HTML_REQUEST_ID_HEADER)
return HeadersAdapter.seal(cleaned)
}If middleware sets cookies on a request via the x-middleware-set-cookie header, mergeMiddlewareCookies parses the cookie string using splitCookiesString, wraps them in a ResponseCookies container, and merges them into the existing request cookies object so that subsequent cookies() calls can access newly written cookies. Sources: packages/next/src/server/async-storage/request-store.ts:130-156
function mergeMiddlewareCookies(
headers: Headers | IncomingHttpHeaders,
existingCookies: RequestCookies | ResponseCookies
) {
if (
'x-middleware-set-cookie' in headers &&
typeof headers['x-middleware-set-cookie'] === 'string'
) {
const setCookieValue = headers['x-middleware-set-cookie']
const responseHeaders = new Headers()
for (const cookie of splitCookiesString(setCookieValue)) {
responseHeaders.append('set-cookie', cookie)
}
const responseCookies = new ResponseCookies(responseHeaders)
for (const cookie of responseCookies.getAll()) {
existingCookies.set(cookie)
}
}
}In Edge runtimes and middleware adapters, execution bridges bind request stores using createRequestStoreForAPI. For example, packages/next/src/server/web/adapter.ts constructs implicit tags, wraps cookie updates, and runs the request and work async storage scopes around the middleware handler: Sources: packages/next/src/server/web/adapter.ts:281-346
const requestStore = createRequestStoreForAPI(
request,
request.nextUrl,
implicitTags,
onUpdateCookies,
previewProps
)
return await workAsyncStorage.run(workStore, () =>
workUnitAsyncStorage.run(
requestStore,
params.handler,
request,
event
)
)Similarly, the cache probe worker (use-cache-probe-worker.ts) initializes a throwaway request store from a serializable request snapshot without an underlying Node.js socket, enabling isolated re-executions for 'use cache' deadlock detection: Sources: packages/next/src/server/dev/use-cache-probe-worker.ts:155-167
const workUnitStore = createRequestStore({
phase: 'render',
headers: new Headers(msg.request.headers),
onUpdateCookies: undefined,
url: { pathname: msg.request.urlPathname, search: msg.request.urlSearch },
rootParams: msg.request.rootParams,
implicitTags: { tags: [], expirationsByCacheKind: new Map() },
resumeDataCache: null,
previewProps: undefined,
isHmrRefresh: msg.request.isHmrRefresh,
serverComponentsHmrCache: undefined,
fallbackParams: null,
})The Next.js rendering engine relies on a dual-store architecture managed via Node.js AsyncLocalStorage instances: WorkStore (via workAsyncStorage) and WorkUnitStore (via workUnitAsyncStorage). While WorkStore tracks top-level request and build configuration metadata across the entire render tree, WorkUnitStore encapsulates specific execution scopes such as incoming requests, cache boundaries ("use cache" or unstable_cache), prerenders, and static generation parameter sweeps. This separation ensures that request-specific state cannot leak into cached scopes. Sources: packages/next/src/server/app-render/work-async-storage.external.ts:1-156, packages/next/src/server/app-render/work-unit-async-storage.external.ts:369-426
WorkStore is initialized through createWorkStore and tracks global options, build identifiers, timeout configurations, and deduplication maps for fetch metrics and cache invocations. Sources: packages/next/src/server/app-render/work-async-storage.external.ts:17-151, packages/next/src/server/async-storage/work-store.ts:83-162
Sources: packages/next/src/server/app-render/work-async-storage.external.ts:17-151, packages/next/src/server/async-storage/work-store.ts:83-162
The determination of static generation follows strict rules based on render options: Sources: packages/next/src/server/async-storage/work-store.ts:109-114
const isStaticGeneration =
!renderOpts.shouldWaitOnAllReady &&
!renderOpts.supportsDynamicResponse &&
!renderOpts.isDraftMode &&
!renderOpts.isPossibleServerActionWorkUnitStore is a union type representing different execution units. When entering a cache scope, createUseCacheStore constructs a UseCacheStore that shadows any outer request store, explicitly preventing the leakage of request-specific objects like unmasked cookies or headers while selectively copying required properties. Sources: packages/next/src/server/app-render/work-unit-async-storage.external.ts:372-422, packages/next/src/server/use-cache/use-cache-wrapper.ts:640-720
Sources: packages/next/src/server/app-render/work-unit-async-storage.external.ts:372-422, packages/next/src/server/use-cache/use-cache-wrapper.ts:640-720
Warning
Inside an UnstableCacheStore, rootParams is always hardcoded as undefined. Any nested "use cache" function attempting to access route parameters in this context will encounter undefined and throw an error. Sources: packages/next/src/server/app-render/work-unit-async-storage.external.ts:391-399
When generating a cache entry, Next.js detaches from request-specific contexts by executing through a series of wrappers that clear and restore the storage layers: Sources: packages/next/src/server/use-cache/use-cache-wrapper.ts:585-638
generateCacheEntry() calls workStore.runInCleanSnapshot(), which invokes generateCacheEntryWithRestoredWorkStore(). This function resets the asynchronous context and binds the work store via workAsyncStorage.run(), before passing execution to generateCacheEntryWithCacheContext(): Sources: packages/next/src/server/use-cache/use-cache-wrapper.ts:585-638
function generateCacheEntry(
workStore: WorkStore,
cacheContext: CacheContext,
clientReferenceManifest: DeepReadonly<ClientReferenceManifest>,
encodedArguments: FormData | string,
fn: (...args: unknown[]) => Promise<unknown>,
timeoutError: UseCacheTimeoutError,
deadlockError: UseCacheDeadlockError | undefined
) {
return workStore.runInCleanSnapshot(
generateCacheEntryWithRestoredWorkStore,
workStore,
cacheContext,
clientReferenceManifest,
encodedArguments,
fn,
timeoutError,
deadlockError
)
}Note
Request stores and prerender stores are explicitly excluded from cache generation snapshots. This guarantees that request-scoped elements such as cookies() inside a React.cache() invocation cannot leak into or contaminate cached outputs. Sources: packages/next/src/server/use-cache/use-cache-wrapper.ts:620-626
The headers() and cookies() functions provide asynchronous access to incoming HTTP request headers and request-response cookie stores. These APIs integrate with asynchronous storage to enforce dynamic tracking, validate execution phases, and prevent synchronous access or improper usage across cache scopes and background callbacks. Sources: packages/next/src/server/request/headers.ts:33-56, packages/next/src/server/request/cookies.ts:35-50
Cookies can only be modified when the request store is operating within specific lifecycle phases, such as during a Server Action. The areCookiesMutableInCurrentPhase function inspects the requestStore.phase property to determine whether mutation is permitted. Sources: packages/next/src/server/web/spec-extension/adapters/request-cookies.ts:208-210
export function areCookiesMutableInCurrentPhase(requestStore: RequestStore) {
return requestStore.phase === 'action'
}When mutation methods like set or delete are invoked on cookies(), createCookiesWithMutableAccessCheck wraps the target store and triggers ensureCookiesAreStillMutable(). If the current phase has transitioned (such as moving from action to render or render to after), mutation attempts throw a ReadonlyRequestCookiesError. Sources: packages/next/src/server/web/spec-extension/adapters/request-cookies.ts:181-227
Warning
Attempting to modify cookies via cookies().set() or cookies().delete() outside of a Server Action or Route Handler phase triggers ReadonlyRequestCookiesError, halting execution with an unmodifiable cookies error. Sources: packages/next/src/server/web/spec-extension/adapters/request-cookies.ts:12-22
Both headers() and cookies() return promises that resolve to read-only or mutable collections. To discourage synchronous access anti-patterns (such as calling properties directly on the returned promise), Next.js instruments the promise objects with warning descriptors in development mode. Sources: packages/next/src/server/request/headers.ts:250-271, packages/next/src/server/request/cookies.ts:259-278
Sources: packages/next/src/server/request/headers.ts:250-271, packages/next/src/server/request/cookies.ts:259-278
Note
Synchronously accessing methods or properties on the unresolved headers() or cookies() promise in development invokes createHeadersAccessError or createCookiesAccessError, reminding developers to unwrap the promise using await or React.use(). Sources: packages/next/src/server/request/headers.ts:317-327, packages/next/src/server/request/cookies.ts:324-334
The after() API allows developers to schedule callbacks and promises to execute after the current request finishes processing. Managing this deferred work relies on the AfterContext class, AfterRunner, and the afterTaskAsyncStorage instance to preserve request execution contexts and manage task error handling across background boundaries. Sources: packages/next/src/server/after/after.ts:6-21, packages/next/src/server/after/after-context.ts:21-37, packages/next/src/server/after/run-with-after.ts:12-33
When an after() task is submitted, execution flows through validation and queue management steps. The following call chain illustrates how a task moves from invocation to execution: Sources: packages/next/src/server/after/after.ts:9-20, packages/next/src/server/after/after-context.ts:39-102
after() → workAsyncStorage.getStore() → afterContext.after() → addCallback() → bindSnapshot() → afterTaskAsyncStorage.run() → callbackQueue.add()
Sources: packages/next/src/server/after/after.ts:9-20, packages/next/src/server/after/after-context.ts:39-102
Warning
Calling after() outside of a request scope throws an error (\after` was called outside a request scope), as it requires an active workStorecontaining an initializedafterContext`. Sources: packages/next/src/server/after/after.ts:10-17
AfterContext Options and PropertiesThe behavior and lifecycle of deferred tasks are governed by configuration options passed into AfterContext. Sources: packages/next/src/server/after/after-context.ts:15-37
The AfterRunner class orchestrates request lifecycle closure and error boundaries using AwaiterOnce, CloseController, and a detached promise tracker. Sources: packages/next/src/server/after/run-with-after.ts:12-33
export class AfterRunner {
private awaiter = new AwaiterOnce()
private closeController = new CloseController()
private finishedWithoutErrors = new DetachedPromise<void>()
readonly context: Ctx = {
waitUntil: this.awaiter.waitUntil.bind(this.awaiter),
onClose: this.closeController.onClose.bind(this.closeController),
onTaskError: (error) => this.finishedWithoutErrors.reject(error),
}
public async executeAfter() {
this.closeController.dispatchClose()
await this.awaiter.awaiting()
this.finishedWithoutErrors.resolve()
return this.finishedWithoutErrors.promise
}
}Note
When a callback or promise passed to after() throws or rejects, reportTaskError catches the error, logs it via console.error, and triggers onTaskError if defined, wrapping any handler failures in an InvariantError. Sources: packages/next/src/server/after/after-context.ts:127-151