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.
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.
Browser
receives while streaming
Node API
returns upstream.body
File Store
holds the real bytes
> waiting for sequence to start...
// 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.
// Anti-pattern: loads the full file first
const upstream = await fetch(FILE_ORIGIN + "/" + id)
const bytes = await upstream.arrayBuffer()
return new Response(bytes)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.
// upstream chunks will appear here
remove secret
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.
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)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",
},
})
}