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:
Stream handling in Next.js bridges the gap between heterogeneous rendering environments—supporting Node.js Readable/PassThrough streams, standard Web ReadableStream instances, and incoming HTTP body streams. During server rendering and response generation, streaming architectures allow server components (RSC) and React Fizz SSR pipelines to emit chunks incrementally to clients, reducing time-to-first-byte (TTFB) and enabling partial prerendering (PPR) or streaming HTML hydration.
Sources: packages/next/src/server/app-render/stream-ops.ts:1-9
Because Next.js runs across different runtimes (Node.js vs. Edge) and bundlers (Webpack vs. Turbopack), stream handling employs compile-time switches, conditional adapters, and buffered transform streams. These mechanisms guarantee backpressure management, safe stream teeing and replaying during prerendering, and clean pipe propagation to Node.js ServerResponse objects.
Sources: packages/next/src/server/app-render/stream-ops.node.ts:1-10, packages/next/src/server/pipe-readable.ts:124-147
Next.js unifies Node.js streams and Web streams via conditional compilation and shared interfaces. The core stream operations router (stream-ops.ts) dynamically resolves to either stream-ops.node.ts or stream-ops.web.ts depending on whether process.env.__NEXT_USE_NODE_STREAMS is active and whether the target is the Edge runtime.
Sources: packages/next/src/server/app-render/stream-ops.ts:27-31
Both underlying modules export an identical type surface defined as AnyStream:
export type AnyStream = ReadableStream<Uint8Array> | ReadableThis structural identity allows stream operators (such as continueFizzStream, chainStreams, and streamToBuffer) to accept either stream type without runtime casting. When running under Node.js with native stream optimizations enabled, Web Streams produced by React are converted to Node.js Readable instances using Readable.fromWeb(), allowing Next.js to leverage Node's high-performance pipeline primitives and backpressure management.
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:134-157
Sources: packages/next/src/server/app-render/stream-ops.ts:27-31, packages/next/src/server/app-render/stream-ops.node.ts:1-17, packages/next/src/server/app-render/stream-ops.web.ts:1-29
When delivering rendered output to an HTTP client via RenderResult.pipeToNodeResponse(res) or pipeToNodeResponse(), Next.js bridges ReadableStream or Node Readable instances directly to Node.js ServerResponse streams.
Sources: packages/next/src/server/render-result.ts:405-417
For Web ReadableStream inputs, pipe-readable.ts creates a custom WritableStream adapter (createWriterFromResponse) that manages header flushing, OTEL performance metrics, and backpressure:
export async function pipeToNodeResponse(
readable: ReadableStream<Uint8Array>,
res: ServerResponse,
waitUntilForEnd?: Promise<unknown>
) {
try {
const { errored, destroyed } = res
if (errored || destroyed) return
const controller = createAbortController(res)
const writer = createWriterFromResponse(res, waitUntilForEnd)
await readable.pipeTo(writer, { signal: controller.signal })
} catch (err: any) {
if (isAbortError(err)) return
throw new Error('failed to pipe response', { cause: err })
}
}Important
Headers are deliberately not flushed until the first chunk is written (if (!started) { started = true; res.flushHeaders(); }). This ensures that status codes, cookies, and headers can still be modified during early stream rendering stages before bytes hit the socket.
Sources: packages/next/src/server/pipe-readable.ts:50-80
Backpressure is governed by the return value of res.write(chunk). If res.write() returns false, indicating that the underlying kernel buffer is saturated, the writer awaits the response drain event before continuing the pipe operation:
const ok = res.write(chunk)
if (!ok) {
await drained.promise
drained = new DetachedPromise<void>()
}During static generation or Partial Prerendering (PPR), Next.js must frequently split or re-use a single React Server Components (RSC) render stream across multiple consumers (e.g., generating shell markup, inlined data scripts, and static payloads). Because standard ReadableStream.tee() or Node streams cannot be consumed multiple times without specialized handling, Next.js implements ReplayableNodeStream and ReactServerResult.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:14-99
ReplayableNodeStream buffers incoming chunks into memory while simultaneously notifying active subscribers. When createReplayStream() is called, it constructs a new Node.js Readable that replays all previously buffered chunks via pull-based _read() execution before forwarding live chunks as they arrive:
const stream = new ReadableCtor({
read() {
if (!bufferDrained) {
bufferDrained = true
for (let i = bufferIndex; i < bufferedChunks.length; i++) {
this.push(bufferedChunks[i])
}
bufferIndex = bufferedChunks.length
if (isDone) {
this.push(null)
}
}
},
})Note
Buffered chunks are delivered via pull-based _read() rather than pushed eagerly. This prevents asynchronous task scheduling from capturing an empty AsyncLocalStorage context when createReplayStream() is invoked outside request scopes.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:140-149
Incoming HTTP request bodies (IncomingMessage) are processed as streams during API route execution, middleware processing, and Server Action payload decoding. Because a Node.js request stream can only be read once, getCloneableBody() provides body duplication capabilities with strict memory limits.
Sources: packages/next/src/server/body-streams.ts:43-58
export function getCloneableBody<T extends IncomingMessage>(
readable: T,
sizeLimit?: number
): CloneableBody {
let buffered: Readable | null = null
// ...
return {
cloneBodyStream() {
const input = buffered ?? readable
const p1 = new PassThrough()
const p2 = new PassThrough()
let bytesRead = 0
const bodySizeLimit = sizeLimit ?? DEFAULT_BODY_CLONE_SIZE_LIMIT
let limitExceeded = false
input.on('data', (chunk) => {
if (limitExceeded) return
bytesRead += chunk.length
if (bytesRead > bodySizeLimit) {
limitExceeded = true
p1.push(null)
p2.push(null)
return
}
p1.push(chunk)
p2.push(chunk)
})
// ...
buffered = p2
return p1
}
}
}When cloneBodyStream() is invoked, it taps into the input stream via dual PassThrough streams (p1 and p2), accumulating a buffered copy (p2) while streaming data to the caller (p1). If the accumulated payload exceeds DEFAULT_BODY_CLONE_SIZE_LIMIT (10MB), a warning is logged and both streams are terminated early to prevent unbounded memory consumption.
Sources: packages/next/src/server/body-streams.ts:6-6, packages/next/src/server/body-streams.ts:79-116
For RSC responses rendered during App Router navigation, Flight data chunks must be injected into the HTML document as self-executing script tags (self.__next_f.push(...)). This is handled by createNodeInlinedDataStream and createInlinedDataReadableStream.
Sources: packages/next/src/server/app-render/stream-ops.node.ts:908-935, packages/next/src/server/app-render/use-flight-response.tsx:165-220
The stream reads binary or text chunks from the RSC flight stream, serializes them into JSON instructions, and wraps them in HTML script tags with proper escaping (htmlEscapeJsonString). Binary chunks that cannot be decoded as valid UTF-8 strings are automatically converted to Base64 data instructions:
const base64 = Buffer.from(
chunk.buffer,
chunk.byteOffset,
chunk.byteLength
).toString('base64')
htmlInlinedData = htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_BINARY, base64])
)Conversely, when decoding Server Actions (action-handler.ts), incoming multipart form data or JSON payloads are validated against bodySizeLimitBytes (defaulting to 1MB) using a size-limiting transform stream before being passed to decodeReply or busboy:
const sizeLimitTransform = new Transform({
transform(chunk, encoding, callback) {
size += Buffer.byteLength(chunk, encoding)
if (size > bodySizeLimitBytes) {
callback(new ApiError(413, `Body exceeded ${bodySizeLimit} limit.`))
return
}
callback(null, chunk)
},
})In development mode, Next.js streams React debug channel events and HMR payloads to the browser over WebSockets. To prevent excessive network overhead from small, frequent stream pushes, chunks are batched using createBufferedTransformStream (Web Streams) or createNodeBufferedTransformStream (Node streams) with a 128KB buffer threshold (MAX_DEBUG_CHANNEL_BATCH_BYTES).
Sources: packages/next/src/server/dev/debug-channel.ts:10-12, packages/next/src/server/dev/debug-channel.ts:68-93
export function createBufferedTransformStream(
options: BufferedTransformOptions = {}
): TransformStream<Uint8Array, Uint8Array> {
const { maxBufferByteLength = Infinity } = options
let bufferedChunks: Array<Uint8Array> = []
let bufferByteLength: number = 0
let pending: DetachedPromise<void> | undefined
const flush = (controller: TransformStreamDefaultController) => {
if (bufferedChunks.length === 0) return
const chunk = new Uint8Array(bufferByteLength)
let copiedBytes = 0
for (let i = 0; i < bufferedChunks.length; i++) {
chunk.set(bufferedChunks[i], copiedBytes)
copiedBytes += bufferedChunks[i].byteLength
}
bufferedChunks.length = 0
bufferByteLength = 0
controller.enqueue(chunk)
}
// ...
}The transformation buffers incoming chunks until bufferByteLength >= maxBufferByteLength, or schedules an immediate flush via microtask (scheduleImmediate) if the buffer is below the limit, ensuring low latency while batching small packets.
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:263-292
When an HMR event or HTML request debug channel is established, connectReactDebugChannel resolves the stream type, applies this buffered transform stream, and pushes data chunks to the browser.
Sources: packages/next/src/server/dev/debug-channel.ts:28-97, packages/next/src/server/dev/hot-reloader-turbopack.ts:1278-1282
The following sequence traces how a rendered output (RenderResult) is piped to an HTTP client response object:
Sources: packages/next/src/server/render-result.ts:405-417
RenderResult.pipeToNodeResponse(res) checks if the response payload is a Node.js Readable stream. If so, it delegates to pipeNodeReadableToNodeResponse(); otherwise, it extracts .readable (a Web ReadableStream) and calls pipeToNodeResponse().
Sources: packages/next/src/server/render-result.ts:405-417pipeToNodeResponse() creates an AbortController bound to the underlying ServerResponse close/error events via createAbortController(res), initializes createWriterFromResponse(), and executes readable.pipeTo(writer, { signal }).
Sources: packages/next/src/server/pipe-readable.ts:124-147createWriterFromResponse.write(chunk) intercepts the first emitted chunk, flushes response headers (res.flushHeaders()), records OTEL client component metrics, and calls res.write(chunk).
Sources: packages/next/src/server/pipe-readable.ts:20-84res.write(chunk) returns false, the writer awaits drained.promise (which resolves on the Node drain event) before proceeding.
Sources: packages/next/src/server/pipe-readable.ts:83-98close() awaits any pending waitUntil background promises before calling res.end() and resolving the finish promise.
Sources: packages/next/src/server/pipe-readable.ts:109-121Sources: packages/next/src/server/render-result.ts:405-417, packages/next/src/server/pipe-readable.ts:20-147
Sources: packages/next/src/server/app-render/stream-ops.ts:27-31, packages/next/src/server/app-render/app-render-prerender-utils.ts:99-149, packages/next/src/server/body-streams.ts:79-116, packages/next/src/server/dev/debug-channel.ts:10-12