Relevant source files
The following files were used as context for generating this wiki page:
- packages/next/src/server/stream-utils/node-web-streams-helper.ts
- packages/next/src/server/use-cache/use-cache-wrapper.ts
- packages/next/src/server/app-render/stream-ops.node.ts
- packages/next/src/server/app-render/instant-validation/stream-utils.ts
- packages/next/src/server/app-render/app-render-prerender-utils.ts
- packages/next/src/server/render-result.ts
- packages/next/src/server/pipe-readable.ts
- packages/next/src/server/app-render/instant-validation/instant-validation.tsx
- packages/next/src/server/app-render/stream-ops.web.ts
- packages/next/src/server/app-render/use-flight-response.tsx
- packages/next/src/server/app-render/collect-segment-data.tsx
- packages/next/src/build/templates/app-page-runtime.ts
- packages/next/src/client/components/segment-cache/cache.ts
- packages/next/src/client/components/router-reducer/fetch-server-response.ts
- packages/next/src/server/og/cache-image-response.ts
- packages/next/src/server/stream-utils/node-buffered-transform-stream.ts
- packages/next/src/server/app-render/stream-ops.ts
- packages/next/src/server/body-streams.ts
- packages/next/src/server/stream-utils/uint8array-helpers.ts
- packages/next/src/server/dev/debug-channel.ts
- packages/next/src/server/dev/hot-reloader-turbopack.ts
Overview
Stream handling manages the asynchronous generation, transformation, piping, and consumption of response data across both Node.js streams and Web standard streams within Next.js. It bridges disparate streaming ecosystems, enabling efficient serialization, binary manipulation, chunk buffering, and multi-stage prerender handling for server-side rendering pipelines. Sources: packages/next/src/server/app-render/stream-ops.node.ts:1-10, packages/next/src/server/app-render/stream-ops.web.ts:1-8
Dual Runtime Stream Abstractions
Compile-Time Switcher Architecture
The rendering pipeline abstracts stream operations by conditionally loading implementation modules based on the compile-time flag process.env.__NEXT_USE_NODE_STREAMS. When this flag evaluates to a truthy value, stream-ops.ts loads stream-ops.node.ts to utilize Node.js pipeable stream APIs and node:stream primitives. Otherwise, it requires stream-ops.web.ts to execute web-standard ReadableStream operations, which is the default for Edge bundles where Node.js streams are unavailable. Both implementation modules define their exported AnyStream type against the identical type surface defined in app-render-prerender-utils, allowing the switcher to assign either module without requiring type casting or wrapper assertions.
type WebMod = typeof import('./stream-ops.web')
let _m: WebMod
if (process.env.__NEXT_USE_NODE_STREAMS) {
_m = require('./stream-ops.node') as typeof import('./stream-ops.node')
} else {
_m = require('./stream-ops.web') as typeof import('./stream-ops.web')
}Sources: packages/next/src/server/app-render/stream-ops.ts:24-31
| Module File | Target Runtime / Condition | Underlying Stream APIs |
|---|---|---|
stream-ops.node.ts | process.env.__NEXT_USE_NODE_STREAMS (truthy) | Node.js Readable, PassThrough, Transform, node:stream |
stream-ops.web.ts | Edge bundles or standard Web runtimes | Web ReadableStream, TransformStream |
stream-ops.ts | Compile-time entry point | Re-exports unified function bindings from _m |
Sources: packages/next/src/server/app-render/stream-ops.node.ts:1-17, packages/next/src/server/app-render/stream-ops.web.ts:1-27, packages/next/src/server/app-render/stream-ops.ts:24-31
Runtime Conversion and Interoperability
To bridge between native Node.js streams and web-standard streams during rendering operations, node-web-streams-helper.ts and stream-ops.node.ts provide interconversion utilities. The function webToReadable handles environment-specific bundle resolution via require('node:stream'), __non_webpack_require__, or Turbopack references, returning an instance via Readable.fromWeb() when presented with a web stream. Conversely, nodeReadableToWebReadableStream converts Node streams using Readable.toWeb() or preserves existing web streams directly.
function webToReadable(
stream: ReadableStream<Uint8Array> | import('node:stream').Readable
): import('node:stream').Readable {
if (process.env.NEXT_RUNTIME === 'edge') {
throw new Error('webToReadable cannot be used in the edge runtime')
} else {
let Readable: typeof import('node:stream').Readable
if (process.env.TURBOPACK) {
Readable = (require('node:stream') as typeof import('node:stream'))
.Readable
} else if (
process.env.__NEXT_BUNDLER === 'Webpack' ||
process.env.__NEXT_BUNDLER === 'Rspack'
) {
Readable = (
__non_webpack_require__('node:stream') as typeof import('node:stream')
).Readable
} else {
Readable = (require('node:stream') as typeof import('node:stream'))
.Readable
}
if (stream instanceof Readable) {
return stream
}
return Readable.fromWeb(stream as import('stream/web').ReadableStream)
}
}Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:145-171, packages/next/src/server/app-render/stream-ops.node.ts:109-127
Warning
webToReadable throws an error if invoked when process.env.NEXT_RUNTIME === 'edge', preventing unauthorized access to Node.js stream modules within Edge runtimes.
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:148-150
Stream Transformation and Html Injection
Flight Payload and Inlined Data Stream Construction
The rendering pipeline embeds React Server Component payloads and form state directly into HTML outputs using inline script tags via createInlinedDataReadableStream. Initialization instructions bootstrap the client router with INLINE_FLIGHT_PAYLOAD_BOOTSTRAP (0), followed optionally by INLINE_FLIGHT_PAYLOAD_FORM_STATE (2) if form state is present. As the upstream flight stream yields chunks, createInlinedDataReadableStream reads them, attempting a UTF-8 text decode with strict flag validation; binary or non-UTF-8 chunks fall back to base64 encoding using Buffer.from or btoa and are tagged as INLINE_FLIGHT_PAYLOAD_BINARY (3).
export function createInlinedDataReadableStream(
flightStream: ReadableStream<Uint8Array>,
nonce: string | undefined,
formState: unknown | null
): ReadableStream<Uint8Array> {
const startScriptTag = nonce
? `<script nonce="${htmlEscapeAttributeString(nonce)}">`
: '<script>'
const flightReader = flightStream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
const readable = new ReadableStream({
type: 'bytes',
start(controller) {
try {
writeInitialInstructions(controller, startScriptTag, formState)
} catch (error) {
// during encoding or enqueueing forward the error downstream
controller.error(error)
}
},
async pull(controller) {
try {
const { done, value } = await flightReader.read()
if (value) {
try {
const decodedString = decoder.decode(value, { stream: !done })
// The chunk cannot be decoded as valid UTF-8 string as it might
// have arbitrary binary data.
writeFlightDataInstruction(
controller,
startScriptTag,
decodedString
)
} catch {
// The chunk cannot be decoded as valid UTF-8 string.
writeFlightDataInstruction(controller, startScriptTag, value)
}
}
if (done) {
controller.close()
}
} catch (error) {
// There was a problem in the upstream reader or during decoding or enqueuing
// forward the error downstream
controller.error(error)
}
},
})
return readable
}Sources: packages/next/src/server/app-render/use-flight-response.tsx:14-17, packages/next/src/server/app-render/use-flight-response.tsx:164-219
| Constant Name | Numeric Value | Description |
|---|---|---|
INLINE_FLIGHT_PAYLOAD_BOOTSTRAP | 0 | Bootstraps the __next_f script array queue |
INLINE_FLIGHT_PAYLOAD_DATA | 1 | Inlines decoded string flight data chunks |
INLINE_FLIGHT_PAYLOAD_FORM_STATE | 2 | Inlines server form state alongside bootstrap data |
INLINE_FLIGHT_PAYLOAD_BINARY | 3 | Inlines non-UTF-8 binary chunks via base64 encoding |
Sources: packages/next/src/server/app-render/use-flight-response.tsx:14-17
Node.js Transform Operations and Interleaving
Node.js stream operations implement stream transformation via Transform instances such as createFlightDataInjectionTransform and createHeadInsertionTransform. The flight data injector manages concurrent streaming by either delaying data insertion until the first HTML chunk via atLeastOneTask() or yielding tasks to prioritize HTML output over RSC data streams. Head injection transforms locate the encoded closing </head> tag (ENCODED_TAGS.CLOSED.HEAD) within HTML chunks using indexOfUint8Array, splicing the server-inserted metadata or scripts immediately before the tag, or prepending them if no closing head tag is found during partial prerender resumption.
function createFlightDataInjectionTransform(
dataStream: Readable,
delayDataUntilFirstHtmlChunk: boolean
): Transform {
let htmlStreamFinished = false
let pull: Promise<void> | null = null
let donePulling = false
function startOrContinuePulling(target: Transform) {
if (!pull) {
pull = startPulling(target)
}
return pull
}
async function startPulling(target: Transform) {
if (delayDataUntilFirstHtmlChunk) {
// Buffer the inlined data stream until we've left the current Task so
// it's inserted after flushing the shell.
await atLeastOneTask()
}
try {
const iterator = dataStream[Symbol.asyncIterator]()
while (true) {
const { done, value } = await iterator.next()
if (done) {
donePulling = true
return
}
// Prioritize HTML over RSC data: the SSR render produces HTML from
// the same RSC stream, so yield a task to let HTML flush first.
if (!delayDataUntilFirstHtmlChunk && !htmlStreamFinished) {
await atLeastOneTask()
}
target.push(value)
}
} catch (err) {
target.destroy(err as Error)
}
}
const nodeTransform = new Transform({
transform(chunk, _encoding, callback) {
this.push(chunk)
if (delayDataUntilFirstHtmlChunk) {
startOrContinuePulling(this)
}
callback()
},
flush(callback) {
htmlStreamFinished = true
if (donePulling) {
callback()
return
}
startOrContinuePulling(this).then(
() => callback(),
(err) => callback(err as Error)
)
},
})
if (!delayDataUntilFirstHtmlChunk) {
startOrContinuePulling(nodeTransform)
}
return nodeTransform
}
…
function createHeadInsertionTransform(
insert: () => Promise<string>
): Transform {
let inserted = false
let hasBytes = false
return new Transform({
async transform(chunk, _encoding, callback) {
hasBytes = trueSources: packages/next/src/server/app-render/stream-ops.node.ts:134-203, packages/next/src/server/app-render/stream-ops.node.ts:211-229
Warning
createInlinedDataReadableStream enforces fatal: true on its TextDecoder instance to catch malformed UTF-8 byte sequences immediately and force base64 serialization.
Sources: packages/next/src/server/app-render/use-flight-response.tsx:174-174, packages/next/src/server/app-render/use-flight-response.tsx:201-204
Binary Chunk Searching and Manipulation
Uint8Array Utilities and Pattern Matching
Stream and buffer processing operations rely on byte-level helpers defined in uint8array-helpers.ts for searching, comparing, and modifying raw binary chunks without relying on high-level string conversions. The indexOfUint8Array function searches for a target Uint8Array pattern b within a haystack Uint8Array a. When the global Buffer environment is available, it wraps the view and delegates to Node's native Buffer.indexOf implementation; otherwise, it executes a manual byte-by-byte nested scan over the array bounds, returning the starting index or -1 upon failure.
export function indexOfUint8Array(a: Uint8Array, b: Uint8Array) {
if (b.length === 0) return 0
if (a.length === 0 || b.length > a.length) return -1
// Use Node's native implementation when available.
if (typeof Buffer !== 'undefined') {
const haystack = Buffer.isBuffer(a)
? a
: Buffer.from(a.buffer, a.byteOffset, a.byteLength)
return haystack.indexOf(b)
}
// start iterating through `a`
for (let i = 0; i <= a.length - b.length; i++) {
let completeMatch = true
// from index `i`, iterate through `b` and check for mismatch
for (let j = 0; j < b.length; j++) {
// if the values do not match, then this isn't a complete match, exit `b` iteration early and iterate to next index of `a`.
if (a[i + j] !== b[j]) {
completeMatch = false
break
}
}
if (completeMatch) {
return i
}
}
return -1
}Sources: packages/next/src/server/stream-utils/uint8array-helpers.ts:4-34
Strict equality between binary buffers is validated through isEquivalentUint8Arrays, which checks length equality first before performing a linear element-wise comparison. For sub-buffer removal, removeFromUint8Array locates pattern b via indexOfUint8Array and slices or constructs a new Uint8Array excluding the matched segment, returning the original array instance unchanged if the pattern is absent.
* Check if two Uint8Arrays are strictly equivalent.
*/
export function isEquivalentUint8Arrays(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
/**
* Remove Uint8Array `b` from Uint8Array `a`.
*
* If `b` is not in `a`, `a` is returned unchanged.
*
* Otherwise, the function returns a new Uint8Array instance with size `a.length - b.length`
*/
export function removeFromUint8Array(a: Uint8Array, b: Uint8Array) {
const tagIndex = indexOfUint8Array(a, b)
if (tagIndex === 0) return a.subarray(b.length)
if (tagIndex > -1) {
const removed = new Uint8Array(a.length - b.length)
removed.set(a.subarray(0, tagIndex))
removed.set(a.subarray(tagIndex + b.length), tagIndex)
return removed
} else {
return a
}
}Sources: packages/next/src/server/stream-utils/uint8array-helpers.ts:37-67
Note
indexOfUint8Array returns 0 immediately when the search pattern b has a length of zero, matching the behavior of native buffer searches.
Sources: packages/next/src/server/stream-utils/uint8array-helpers.ts:5-5
Concatenation and Stream-to-Buffer Conversion
Converting asynchronous readable streams of binary data into consolidated buffers uses stream readers and array concatenation helpers. The webstreamToUint8Array function consumes a web ReadableStream<Uint8Array> chunk by chunk until completion, passing the collected chunks to an internal concatUint8Arrays helper. This helper calculates the total byte length using reduce, allocates a single contiguous Uint8Array of that size, and copies each individual chunk into its designated offset via result.set(). Similarly, nodestreamToUint8Array iterates over Node.js readable streams, normalizes string chunks into buffers, and combines them using Buffer.concat.
async function streamToChunks(
stream: ReadableStream<Uint8Array>
): Promise<Array<Uint8Array>> {
const reader = stream.getReader()
const chunks: Array<Uint8Array> = []
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
return chunks
}
function concatUint8Arrays(chunks: Array<Uint8Array>): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result
}
export async function webstreamToUint8Array(
stream: ReadableStream<Uint8Array>
): Promise<Uint8Array> {
return concatUint8Arrays(await streamToChunks(stream))
}Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:110-143, packages/next/src/server/stream-utils/node-web-streams-helper.ts:173-181
function concatUint8Arrays(chunks: Array<Uint8Array>): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result
}Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:128-137
| Function Name | Input Type | Output Type | Operation Summary |
|---|---|---|---|
indexOfUint8Array | Uint8Array, Uint8Array | number | Finds starting index of pattern b in a |
isEquivalentUint8Arrays | Uint8Array, Uint8Array | boolean | Verifies strict length and byte equality |
removeFromUint8Array | Uint8Array, Uint8Array | Uint8Array | Slices or splices out occurrence of b from a |
webstreamToUint8Array | ReadableStream<Uint8Array> | Promise<Uint8Array> | Consumes web stream chunks and concatenates them |
nodestreamToUint8Array | AnyStream | Promise<Uint8Array> | Consumes Node stream chunks via Buffer.concat |
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:110-143, packages/next/src/server/stream-utils/uint8array-helpers.ts:4-67
Buffered Streams and Debug Channels
Buffered Transform Streams and Scheduling
Buffered transform streams coalesce chunks written within the same microtask into a single Uint8Array before pushing downstream, flushing synchronously once the buffer reaches a maximum byte length or microtask queue execution triggers a scheduled flush. In Web standard streams, createBufferedTransformStream tracks bufferedChunks and bufferByteLength, utilizing scheduleImmediate and a pending promise resolver to coordinate flushes.
export function createBufferedTransformStream(
options: BufferedTransformOptions = {}
): TransformStream<Uint8Array, Uint8Array> {
const { maxBufferByteLength = Infinity } = options
let bufferedChunks: Array<Uint8Array> = []
let bufferByteLength: number = 0
let pending: PromiseWithResolvers<void> | undefined
const flush = (controller: TransformStreamDefaultController) => {
try {
if (bufferedChunks.length === 0) {
return
}
const chunk = new Uint8Array(bufferByteLength)
let copiedBytes = 0
for (let i = 0; i < bufferedChunks.length; i++) {
const bufferedChunk = bufferedChunks[i]
chunk.set(bufferedChunk, copiedBytes)
copiedBytes += bufferedChunk.byteLength
}
// We just wrote all the buffered chunks so we need to reset the bufferedChunks array
// and our bufferByteLength to prepare for the next round of buffered chunks
bufferedChunks.length = 0
bufferByteLength = 0
controller.enqueue(chunk)
} catch {
// If an error occurs while enqueuing, it can't be due to this
// transformer. It's most likely caused by the controller having been
// errored (for example, if the stream was cancelled).
}
}
const scheduleFlush = (controller: TransformStreamDefaultController) => {
if (pending) {
return
}
const detached = createPromiseWithResolvers<void>()
pending = detached
scheduleImmediate(() => {
try {
flush(controller)
} finally {
pending = undefined
detached.resolve()
}Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:245-294
For Node.js streams, createNodeBufferedTransformStream provides an equivalent mechanism using node:stream Transform, pushing merged buffers through stream.push(merged) when bufferByteLength >= maxBufferByteLength or when queueMicrotask executes flushBuffered.
export function createNodeBufferedTransformStream(
maxBufferByteLength: number = Infinity
): Transform {
let bufferedChunks: Array<Uint8Array> = []
let bufferByteLength = 0
let flushScheduled = false
function flushBuffered(stream: Transform): void {
if (bufferedChunks.length === 0) return
const merged = new Uint8Array(bufferByteLength)
let copiedBytes = 0
for (let i = 0; i < bufferedChunks.length; i++) {
const bufferedChunk = bufferedChunks[i]
merged.set(bufferedChunk, copiedBytes)
copiedBytes += bufferedChunk.byteLength
}
bufferedChunks.length = 0
bufferByteLength = 0
stream.push(merged)
}
return new Transform({
transform(chunk, _encoding, callback) {
bufferedChunks.push(chunk)
bufferByteLength += chunk.byteLength
if (bufferByteLength >= maxBufferByteLength) {
flushBuffered(this)
} else if (!flushScheduled) {
flushScheduled = true
queueMicrotask(() => {
flushScheduled = false
flushBuffered(this)
})
}
callback()
},
flush(callback) {
flushBuffered(this)
callback()
},
})
}Sources: packages/next/src/server/stream-utils/node-buffered-transform-stream.ts:17-60
Warning
If an error occurs while enqueuing inside a Web stream transformer flush function, it is caught and suppressed because it is typically caused by the controller already being errored from stream cancellation.
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:254-278
React Debug Channel Communication
The development server uses a React debug channel to forward binary debugging chunks from render streams to the browser via WebSockets, batched at a limit defined by MAX_DEBUG_CHANNEL_BATCH_BYTES (128 KB). When an HTML request upgrades its socket connection, connectReactDebugChannelForHtmlRequest retrieves the debug channel by its request ID and hands it to connectReactDebugChannel.
// Chunks are sent to the browser in batches to reduce overhead, flushing
// synchronously once this many bytes have accumulated.
const MAX_DEBUG_CHANNEL_BATCH_BYTES = 128 * 1024
export interface ReactDebugChannelForBrowser {
readonly readable: AnyStream
}
const reactDebugChannelsByHtmlRequestId = new Map<
string,
ReactDebugChannelForBrowser
>()Sources: packages/next/src/server/dev/debug-channel.ts:10-21
export function connectReactDebugChannelForHtmlRequest(
htmlRequestId: string,
sendToClient: (message: HmrMessageSentToBrowser) => void
) {
const debugChannel = reactDebugChannelsByHtmlRequestId.get(htmlRequestId)
if (!debugChannel) {
return
}
reactDebugChannelsByHtmlRequestId.delete(htmlRequestId)
connectReactDebugChannel(htmlRequestId, debugChannel, sendToClient)
}Sources: packages/next/src/server/dev/debug-channel.ts:99-112
The execution trace follows the exact call sequence from HMR socket upgrade down to buffer flushing: onHMR → connectReactDebugChannelForHtmlRequest → connectReactDebugChannel → createBufferedTransformStream → scheduleFlush → flush.
onHMR(req, socket: Socket, head, onUpgrade) {
wsServer.handleUpgrade(req, socket, head, (client) => {
const clientIssues: EntryIssuesMap = new Map()
const subscriptions: Map<string, AsyncIterator<any>> = new Map()
const htmlRequestId = req.url
? new URL(req.url, 'http://n').searchParams.get('id')
: null
// Clients with a request ID are inferred App Router clients. If Cache
// Components is not enabled, we consider those legacy clients. Pages
// Router clients are also considered legacy clients. TODO: Maybe mark
// clients as App Router / Pages Router clients explicitly, instead of
// inferring it from the presence of a request ID.
if (htmlRequestId) {
clientsByHtmlRequestId.set(htmlRequestId, client)
const enableCacheComponents = nextConfig.cacheComponents
if (enableCacheComponents) {
onUpgrade(client, { isLegacyClient: false })
const cacheStatus = cacheStatusesByHtmlRequestId.get(htmlRequestId)
if (cacheStatus !== undefined) {
sendToClient(client, {
type: HMR_MESSAGE_SENT_TO_BROWSER.CACHE_INDICATOR,
state: cacheStatus,
})
cacheStatusesByHtmlRequestId.delete(htmlRequestId)
}
} else {
onUpgrade(client, { isLegacyClient: true })
}
connectReactDebugChannelForHtmlRequest(
htmlRequestId,
sendToClient.bind(null, client)
)
Sources: packages/next/src/server/dev/hot-reloader-turbopack.ts:1539-1574
export function connectReactDebugChannel(
requestId: string,
debugChannel: ReactDebugChannelForBrowser,
sendToClient: (message: HmrMessageSentToBrowser) => void
) {
let finished = false
const stop = () => {
if (finished) {
return
}
finished = true
sendToClient({
type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK,
requestId,
chunk: null,
})
}
const onError = (err: unknown) => {
if (!finished) {
console.error(
new Error('React debug channel stream error', { cause: err })
)
}
stop()
}
const sendChunk = (chunk: Uint8Array) => {
sendToClient({
type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK,
requestId,
chunk,
})
}
const { readable } = debugChannel
if (readable instanceof ReadableStream) {
const reader = readable
.pipeThrough(
createBufferedTransformStream({
maxBufferByteLength: MAX_DEBUG_CHANNEL_BATCH_BYTES,
})
)
.getReader()
Sources: packages/next/src/server/dev/debug-channel.ts:28-74
export function createBufferedTransformStream(
options: BufferedTransformOptions = {}
): TransformStream<Uint8Array, Uint8Array> {
const { maxBufferByteLength = Infinity } = options
let bufferedChunks: Array<Uint8Array> = []
let bufferByteLength: number = 0
let pending: PromiseWithResolvers<void> | undefined
const flush = (controller: TransformStreamDefaultController) => {
try {
if (bufferedChunks.length === 0) {
return
}
const chunk = new Uint8Array(bufferByteLength)
let copiedBytes = 0
for (let i = 0; i < bufferedChunks.length; i++) {
const bufferedChunk = bufferedChunks[i]
chunk.set(bufferedChunk, copiedBytes)
copiedBytes += bufferedChunk.byteLength
}
// We just wrote all the buffered chunks so we need to reset the bufferedChunks array
// and our bufferByteLength to prepare for the next round of buffered chunks
bufferedChunks.length = 0
bufferByteLength = 0
controller.enqueue(chunk)
} catch {
// If an error occurs while enqueuing, it can't be due to this
// transformer. It's most likely caused by the controller having been
// errored (for example, if the stream was cancelled).
}
}
const scheduleFlush = (controller: TransformStreamDefaultController) => {
if (pending) {
return
}
const detached = createPromiseWithResolvers<void>()
pending = detached
scheduleImmediate(() => {
try {
flush(controller)
} finally {
pending = undefined
detached.resolve()
}Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:245-294
Sources: packages/next/src/server/dev/hot-reloader-turbopack.ts:1539-1574, packages/next/src/server/dev/debug-channel.ts:28-112, packages/next/src/server/stream-utils/node-web-streams-helper.ts:245-294
| Function Name | File Location | Input Parameters | Purpose |
|---|---|---|---|
createBufferedTransformStream | node-web-streams-helper.ts | options?: BufferedTransformOptions | Transforms web stream chunks with byte length batching |
createNodeBufferedTransformStream | node-buffered-transform-stream.ts | maxBufferByteLength?: number | Transforms Node.js streams with microtask batching |
connectReactDebugChannel | debug-channel.ts | requestId, debugChannel, sendToClient | Forwards React debug chunks over WebSocket |
connectReactDebugChannelForHtmlRequest | debug-channel.ts | htmlRequestId, sendToClient | Looks up debug channel by request ID and connects |
setReactDebugChannelForHtmlRequest | debug-channel.ts | htmlRequestId, debugChannel | Stores debug channel for an active HTML request |
Sources: packages/next/src/server/stream-utils/node-web-streams-helper.ts:245-247, packages/next/src/server/stream-utils/node-buffered-transform-stream.ts:17-19, packages/next/src/server/dev/debug-channel.ts:28-32, packages/next/src/server/dev/debug-channel.ts:99-102, packages/next/src/server/dev/debug-channel.ts:114-117
Prerender Streams and Response Replay
Replayable Node Stream Buffering and Deferred Delivery
During React Server Component prerendering, experimental prerender operations emit incomplete Flight streams that lack native resume capabilities when closed abruptly. To support multiple downstream consumers without re-running the underlying generator, ReplayableNodeStream captures all incoming chunks from a source Node.js Readable stream while simultaneously notifying registered subscribers.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:10-15, packages/next/src/server/app-render/app-render-prerender-utils.ts:99-119
When createReplayStream() is invoked, buffered chunks are delivered via pull-based _read() operations rather than pushed eagerly. Eager pushing triggers Node.js internal stream scheduling (process.nextTick executing maybeReadMore), which captures an empty AsyncLocalStorage context. Deferring delivery to _read() ensures chunks flow only when the consumer reads within the correct ALS scope, such as inside Fizz's performWork.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:138-150
export class ReplayableNodeStream {
private _chunks: Array<Uint8Array> | null
private _done: boolean
private _error: Error | null
private _subscribers: Set<ReplayableStreamSubscriber>
constructor(stream: Readable) {
this._chunks = []
this._done = false
this._error = null
this._subscribers = new Set()
stream.on('data', (chunk: Buffer | Uint8Array) => {
const buf = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
if (this._chunks !== null) {
this._chunks.push(buf)
}
for (const sub of this._subscribers) {
sub.onChunk(buf)
}
})
stream.on('end', () => {
this._done = true
for (const sub of this._subscribers) {
sub.onEnd()
}
this._subscribers.clear()
})
stream.on('error', (err: Error) => {
this._error = err
for (const sub of this._subscribers) {
sub.onError(err)
}
this._subscribers.clear()
})
}Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:99-136
Important
createReplayStream() must be invoked outside of eager push contexts because immediate data emission schedules microtasks that sever AsyncLocalStorage propagation. Pull-based draining via _read() aligns chunk emission directly with consumer pull timing under the correct execution context.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:138-149
Multi-Stage Prerender Results and Stream Teeing
The ReactServerResult class manages underlying streams based on runtime configurations and whether node streams are enforced via process.env.__NEXT_USE_NODE_STREAMS. It abstracts stream duplication (tee()) and consumption (consume()) across Web standard ReadableStream and Node.js Readable instances.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:14-26
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:28-86
| Method Name | Return Type | Behavior on Consumption / Teeing |
|---|---|---|
ReactServerResult.tee() | AnyStream | Delegates to ReplayableNodeStream.createReplayStream() if replayable, calls tee() on web streams, or converts Node streams via Web Streams API bridge. |
ReactServerResult.consume() | AnyStream | Extracts and detaches the internal stream, setting this._stream or this._replayable to null to prevent double consumption. |
ReactServerPrerenderResult.asChunks() | Array<Uint8Array> | Returns a copy of the buffered chunks without clearing them, throwing an InvariantError if already consumed. |
ReactServerPrerenderResult.asUnclosingStream() | ReadableStream<Uint8Array> | Creates a ReadableStream that yields chunks via pull() but never calls controller.close(). |
ReactServerPrerenderResult.asStream() | ReadableStream<Uint8Array> | Creates a ReadableStream that yields chunks via pull() and calls controller.close() when chunks are exhausted. |
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:28-85, packages/next/src/server/app-render/app-render-prerender-utils.ts:278-328
Warning
Attempting to call tee() or consume() on a ReactServerResult or ReactServerPrerenderResult that has already been consumed throws an immediate error. Once consumed, internal references are nulled out to prevent race conditions during parallel payload serialization.
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:33-37, packages/next/src/server/app-render/app-render-prerender-utils.ts:281-288
Prelude Processing and Late Stream Release
Prelude handling utilities inspect raw ReadableStream<Uint8Array> instances by teeing the stream, reading the initial chunk, and cancelling the peek reader to determine whether the prelude is empty (preludeIsEmpty).
Sources: packages/next/src/server/app-render/app-render-prerender-utils.ts:361-373
For staged renders where execution aborts early, createNodeStreamWithLateRelease provides React with chunks from subsequent phases to populate debug info without triggering additional rendering work. When the releaseSignal aborts, any remaining chunks from the partial set are flushed, followed by all remaining chunks from the complete set (allChunks), before pushing null via setImmediate to close the stream.
Sources: packages/next/src/server/app-render/instant-validation/stream-utils.ts:4-12, packages/next/src/server/app-render/instant-validation/stream-utils.ts:31-49
export function createNodeStreamWithLateRelease(
partialChunks: Array<Uint8Array>,
allChunks: Array<Uint8Array>,
releaseSignal: AbortSignal
): Readable {
if (process.env.NEXT_RUNTIME === 'edge') {
throw new InvariantError(
'createNodeStreamWithLateRelease cannot be used in the edge runtime'
)
} else {
const { Readable } = require('node:stream') as typeof import('node:stream')
let nextIndex = 0
const readable = new Readable({
read() {
while (nextIndex < partialChunks.length) {
this.push(partialChunks[nextIndex])
nextIndex++
}
},
})
releaseSignal.addEventListener(
'abort',
() => {
// Flush any remaining chunks from the original set
while (nextIndex < partialChunks.length) {
readable.push(partialChunks[nextIndex])
nextIndex++
}
// Flush all chunks since we're now aborted and can't schedule
// any new work but these chunks might unblock debugInfo
while (nextIndex < allChunks.length) {
readable.push(allChunks[nextIndex])
nextIndex++
}
setImmediate(() => {
readable.push(null)
})
},
{ once: true }
)
return readable
}
}Sources: packages/next/src/server/app-render/instant-validation/stream-utils.ts:8-55
Http Response Piping and Termination
Piping Readable Streams into Node Server Responses
Next.js bridges standard Web ReadableStream instances and Node.js Readable streams directly into native ServerResponse objects via pipeToNodeResponse and pipeNodeReadableToNodeResponse. Before initiating any data transfer, both functions inspect the Node response state (res.errored and res.destroyed), immediately aborting and returning early if the underlying socket is already dead or faulted. When the response is healthy, pipeToNodeResponse constructs an AbortController bound to the response socket using createAbortController(res) so that client disconnections automatically propagate cancel signals into the active stream pipeline.
export async function pipeToNodeResponse(
readable: ReadableStream<Uint8Array>,
res: ServerResponse,
waitUntilForEnd?: Promise<unknown>,
clientComponentLoadTracker?: ClientComponentLoadTracker
) {
try {
const { errored, destroyed } = res
if (errored || destroyed) return
const controller = createAbortController(res)
const writer = createWriterFromResponse(
res,
waitUntilForEnd,
clientComponentLoadTracker
)
await readable.pipeTo(writer, { signal: controller.signal })
} catch (err: any) {
if (isAbortError(err)) return
throw new Error('failed to pipe response', { cause: err })
}
}Sources: packages/next/src/server/pipe-readable.ts:125-153
Warning
Abort errors thrown during stream piping (such as AbortError or ResponseAbortedName) are intentionally caught and swallowed by isAbortError checks in both pipeToNodeResponse and pipeNodeReadableToNodeResponse. Letting client disconnection errors bubble up would cause unhandled rejection crashes on the server process.
Sources: packages/next/src/server/pipe-readable.ts:13-15, packages/next/src/server/pipe-readable.ts:147-150, packages/next/src/server/pipe-readable.ts:270-272
Response Writer and Backpressure Management
The helper createWriterFromResponse wraps a ServerResponse in a WritableStream<Uint8Array>, managing header flushing, OpenTelemetry performance snapshots for client component loading, and TCP backpressure. When the first chunk is written, headers are flushed and metrics are recorded if NEXT_OTEL_PERFORMANCE_PREFIX is active. If res.write(chunk) returns false, indicating that the kernel buffer is full, the writer awaits the response drain event before proceeding.
export function isAbortError(e: any): e is Error & { name: 'AbortError' } {
return e?.name === 'AbortError' || e?.name === ResponseAbortedName
}Sources: packages/next/src/server/pipe-readable.ts:13-15
| Helper Function / Method | Target Stream Type | Core Mechanism |
|---|---|---|
pipeToNodeResponse | ReadableStream<Uint8Array> | Uses createAbortController(res) and pipeTo with a response-backed WritableStream. |
pipeNodeReadableToNodeResponse | Node.js Readable | Attaches data, drain, end, and error listeners with manual flow control via readable.pause() / resume(). |
createWriterFromResponse | WritableStream<Uint8Array> | Manages drain promises, header flushing, OpenTelemetry metrics, and waitUntilForEnd resolution. |
Sources: packages/next/src/server/pipe-readable.ts:20-123, packages/next/src/server/pipe-readable.ts:125-153, packages/next/src/server/pipe-readable.ts:155-275
Request Body Replacement and Cloning
Incoming request streams read by middlewares or body parsers are managed through getCloneableBody and replaceRequestBody. Because a Node.js IncomingMessage stream can only be consumed once, getCloneableBody taps incoming chunks using a PassThrough stream for consumers while building a replacement readable stream via Readable.create semantics. If the read body exceeds sizeLimit (defaulting to 10MB via DEFAULT_BODY_CLONE_SIZE_LIMIT), a warning is logged and both streams are terminated early via p1.push(null) and p2.push(null).
const DEFAULT_BODY_CLONE_SIZE_LIMIT = 10 * 1024 * 1024 // 10MBSources: packages/next/src/server/body-streams.ts:5
function replaceRequestBody<T extends IncomingMessage>(
base: T,
stream: Readable
): T {
for (const key in stream) {
let v = stream[key as keyof Readable] as any
if (typeof v === 'function') {
v = v.bind(base)
}
base[key as keyof T] = v
}
return base
}Sources: packages/next/src/server/body-streams.ts:22-35
Note
replaceRequestBody copies all properties and binds functions from the buffered replacement stream directly onto the original IncomingMessage instance (base), ensuring that downstream handlers interacting with req continue to function normally after the body stream has been read and cloned.
Sources: packages/next/src/server/body-streams.ts:22-35
Related
On This Page
OverviewDual Runtime Stream AbstractionsCompile-Time Switcher ArchitectureRuntime Conversion and InteroperabilityStream Transformation and Html InjectionFlight Payload and Inlined Data Stream ConstructionNode.js Transform Operations and InterleavingBinary Chunk Searching and ManipulationUint8Array Utilities and Pattern MatchingConcatenation and Stream-to-Buffer ConversionBuffered Streams and Debug ChannelsBuffered Transform Streams and SchedulingReact Debug Channel CommunicationPrerender Streams and Response ReplayReplayable Node Stream Buffering and Deferred DeliveryMulti-Stage Prerender Results and Stream TeeingPrelude Processing and Late Stream ReleaseHttp Response Piping and TerminationPiping Readable Streams into Node Server ResponsesResponse Writer and Backpressure ManagementRequest Body Replacement and CloningRelated