Skip to main content
doc0blade47/next.jsdocumentation home
Sign in

Stream Handling

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

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 FileTarget Runtime / ConditionUnderlying Stream APIs
stream-ops.node.tsprocess.env.__NEXT_USE_NODE_STREAMS (truthy)Node.js Readable, PassThrough, Transform, node:stream
stream-ops.web.tsEdge bundles or standard Web runtimesWeb ReadableStream, TransformStream
stream-ops.tsCompile-time entry pointRe-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 NameNumeric ValueDescription
INLINE_FLIGHT_PAYLOAD_BOOTSTRAP0Bootstraps the __next_f script array queue
INLINE_FLIGHT_PAYLOAD_DATA1Inlines decoded string flight data chunks
INLINE_FLIGHT_PAYLOAD_FORM_STATE2Inlines server form state alongside bootstrap data
INLINE_FLIGHT_PAYLOAD_BINARY3Inlines 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 = true

Sources: 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 NameInput TypeOutput TypeOperation Summary
indexOfUint8ArrayUint8Array, Uint8ArraynumberFinds starting index of pattern b in a
isEquivalentUint8ArraysUint8Array, Uint8ArraybooleanVerifies strict length and byte equality
removeFromUint8ArrayUint8Array, Uint8ArrayUint8ArraySlices or splices out occurrence of b from a
webstreamToUint8ArrayReadableStream<Uint8Array>Promise<Uint8Array>Consumes web stream chunks and concatenates them
nodestreamToUint8ArrayAnyStreamPromise<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 NameFile LocationInput ParametersPurpose
createBufferedTransformStreamnode-web-streams-helper.tsoptions?: BufferedTransformOptionsTransforms web stream chunks with byte length batching
createNodeBufferedTransformStreamnode-buffered-transform-stream.tsmaxBufferByteLength?: numberTransforms Node.js streams with microtask batching
connectReactDebugChanneldebug-channel.tsrequestId, debugChannel, sendToClientForwards React debug chunks over WebSocket
connectReactDebugChannelForHtmlRequestdebug-channel.tshtmlRequestId, sendToClientLooks up debug channel by request ID and connects
setReactDebugChannelForHtmlRequestdebug-channel.tshtmlRequestId, debugChannelStores 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 NameReturn TypeBehavior on Consumption / Teeing
ReactServerResult.tee()AnyStreamDelegates to ReplayableNodeStream.createReplayStream() if replayable, calls tee() on web streams, or converts Node streams via Web Streams API bridge.
ReactServerResult.consume()AnyStreamExtracts 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 / MethodTarget Stream TypeCore Mechanism
pipeToNodeResponseReadableStream<Uint8Array>Uses createAbortController(res) and pipeTo with a response-backed WritableStream.
pipeNodeReadableToNodeResponseNode.js ReadableAttaches data, drain, end, and error listeners with manual flow control via readable.pause() / resume().
createWriterFromResponseWritableStream<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 // 10MB

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

Was this page helpful?