Streaming Responses
Trailhead's park rangers can export every hiking log as a CSV: 20,000 rows that take about ten seconds to gather. Built the usual way, the download sits at "waiting" for ten seconds, then everything arrives at once, and the server holds the whole file in memory meanwhile. Streaming lets the first rows leave while the rest are still being gathered.
The rule
Don't wait for the whole answer: return a stream and send it piece by piece.
A Response body does not have to be a finished string. It can be a ReadableStream: a source you push chunks into over time. Each chunk goes to the client as soon as you push it.
// app/api/export/route.ts
import { getLogBatches } from "@/lib/logs";
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode("date,trail,hiker,minutes\n"));
for await (const batch of getLogBatches(500)) {
const rows = batch.map((l) => `${l.date},${l.trail},${l.hiker},${l.minutes}`);
controller.enqueue(encoder.encode(rows.join("\n") + "\n"));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/csv; charset=utf-8" },
});
}Line by line:
TextEncoderturns text into bytes, which is what a stream carries.enqueuepushes one chunk to the client.closesays the response is complete. Forget it and the client keeps waiting.
On the receiving side, a browser download simply grows as chunks arrive. Code can read chunks as they come with response.body.getReader().
Where streaming shines:
- Large exports: the download starts at once, and the server never holds the whole file.
- Slow, incremental results, such as text from an AI model, shown word by word.
- Progress updates for a long job.
Where it does not help: small, quick responses. Response.json(...) is simpler and just as fast.
The shortcut
Ask "Can part of the answer be useful before the rest exists?" Yes: stream it and enqueue each part when ready. No: build it and send it whole.
Can part be useful early?
Yes. The first 500 rows are valid CSV on their own.
The trap is collecting every row into an array and only then enqueueing it as one chunk. That is a stream in name only: the client still waits for everything.
Drills
Answer fast — trust the rule.
What can a route handler pass as a Response body to stream data?
Inside the stream's start function, which call sends one chunk?
What happens if you never call controller.close()?
Which Trailhead endpoint gains least from streaming?
Remember
- Don't wait for the whole answer: return a
ReadableStreamandenqueueeach piece. - Shortcut: "can part of it be useful before the rest exists?" Then stream.
- Trap: building everything first and enqueueing once, or forgetting
close().
