Node Stream Patterns
StorageNode APIBrowser

Pass-Through + Transform

This page is about one concrete problem: serving large payloads like PDFs without loading the whole body into memory inside your Node API.

Default

Pass-Through

If your API only needs to authorize or route the request, return the upstream stream directly.

When Needed

Transform

If you must inspect or rewrite bytes, transform each chunk and keep the response streamed.

Pass-Through Pattern

The safe default for large files

If your Node API is not changing the PDF, do not read it into memory. Open the upstream stream and return it directly.

Click play to replay the pass-through flow

Browser

receives while streaming

Node API

returns upstream.body

File Store

holds the real bytes

event log

> waiting for sequence to start...

minimal route
// app/api/files/[id]/route.ts
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const upstream = await fetch(FILE_ORIGIN + "/" + id)

  return new Response(upstream.body, {
    headers: {
      "Content-Type":
        upstream.headers.get("content-type") ??
        "application/pdf",
    },
  })
}

Why It Works

Memory stays near the size of active chunks and backpressure, not the size of the whole PDF.

Default Choice

If you are not changing the body, pass it through. This should be your first design, not your fallback.

What To Avoid

Calling .arrayBuffer(), .text(), or .json() forces the entire body into memory before you can respond.

do not do this
// Anti-pattern: loads the full file first
const upstream = await fetch(FILE_ORIGIN + "/" + id)
const bytes = await upstream.arrayBuffer()

return new Response(bytes)
Transform Pattern

Change the stream without buffering the whole file

When you really need to touch the payload, do it chunk-by-chunk or record-by-record. The body should still keep flowing while the transform runs.

Incoming

// upstream chunks will appear here

remove secret

Outgoing
Transform Statusidle

Idle

// transformed chunks will appear here

Rules

Use It When

You must redact, reshape, filter, or annotate the outgoing stream before it reaches the browser.

Keep It Streaming

Transform one chunk or one record at a time. Do not rebuild the full payload in memory first.

Header Warning

If the transform changes byte size, do not trust the original Content-Length anymore.

transform stream
const redact = new TransformStream({
  transform(chunk, controller) {
    const event = JSON.parse(chunk)
    delete event.secret

    controller.enqueue(
      JSON.stringify(event) + "\n"
    )
  },
})

const outbound = upstream.body!
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(splitOnNewlines())
  .pipeThrough(redact)
  .pipeThrough(new TextEncoderStream())

return new Response(outbound)
Implementation

How to build it

Modern route handlers usually end with a Web `ReadableStream`, even when the source started as a Node stream. The default move is pass-through; transform only when the body truly needs to change.

Use this when the file already lives behind another HTTP service.

// app/api/files/[id]/route.ts
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const upstream = await fetch(FILE_ORIGIN + "/" + id)

  return new Response(upstream.body, {
    headers: {
      "Content-Type":
        upstream.headers.get("content-type") ??
        "application/pdf",
    },
  })
}