처리기의 스트리밍 응답과 ReadableStream
인공지능 토큰, 로그 및 점진적 페이로드를 위해 ReadableStream으로 증분 데이터를 반환하십시오.
처리기의 스트리밍 응답과 ReadableStream은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Next.js 15 Fullstack (App Router + Server Actions) 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why Stream a Response?
A normal Route Handler builds the entire body in memory, then sends it all at once. For AI token output, live logs, or large reports, that means the user stares at a blank screen until the very end.
Streaming flips this: you push chunks to the client as they become available. The browser starts rendering the first bytes immediately, time-to-first-byte drops, and you never hold the whole payload in RAM.
ReadableStreamis the Web Standard primitive Next.js 15 uses for this.- You return it directly from a Route Handler inside a
Response. - It works on both the Node.js and Edge runtimes.
The ReadableStream Shape
A ReadableStream is constructed with an object containing a start(controller) method. Inside it you call controller.enqueue(chunk) to push data and controller.close() when finished.
The chunk should be a Uint8Array of bytes. A TextEncoder turns a string into those bytes. This is pure Web API code, so it runs anywhere a modern JS engine exists.
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("Hello, "));
controller.enqueue(encoder.encode("streamed "));
controller.enqueue(encoder.encode("world!"));
controller.close();
},
});
const response = new Response(stream);
console.log("Stream and Response created:", response instanceof Response);Returning a Stream from a Handler
In app/api/.../route.ts you export an HTTP method function (here GET) and return a Response whose body is the stream.
Always set Content-Type. For plain incremental text, text/plain is fine; for structured event streams you would use text/event-stream (covered later).
This is framework code that needs the Next.js server, so it is not standalone-runnable.
// app/api/hello/route.ts
export async function GET(): Promise<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("chunk-1\n"));
controller.enqueue(encoder.encode("chunk-2\n"));
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}Streaming Over Time with async start
The real power shows when chunks arrive over time. The start method can be async, letting you await between enqueues.
Here a small delay simulates work (an AI provider, a slow query, a job step). Each line reaches the client the moment it is enqueued, not when the loop ends.
- Use
awaitto pace output without blocking the event loop. - Never forget
controller.close()or the connection hangs open.
const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const stream = new ReadableStream({
async start(controller) {
for (let i = 1; i <= 3; i++) {
await sleep(50);
controller.enqueue(encoder.encode(`step ${i}\n`));
}
controller.close();
},
});
const reader = stream.getReader();
const decoder = new TextDecoder();
let out = "";
let result = await reader.read();
while (!result.done) {
out += decoder.decode(result.value);
result = await reader.read();
}
console.log(out.trim());Streaming AI Tokens
The classic use case: piping an LLM's token stream straight to the browser so text appears word-by-word. Most AI SDKs expose an async iterable of partial chunks.
You loop over that iterable inside start and enqueue each token's text delta. The user sees the answer build in real time, just like a chat UI.
// app/api/chat/route.ts
import { openai } from "@/lib/openai";
export async function POST(req: Request): Promise<Response> {
const { prompt } = await req.json();
const encoder = new TextEncoder();
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: prompt }],
});
const stream = new ReadableStream({
async start(controller) {
for await (const part of completion) {
const token = part.choices[0]?.delta?.content ?? "";
if (token) controller.enqueue(encoder.encode(token));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}Server-Sent Events (SSE) Format
For structured, named events the browser's EventSource understands, use the SSE wire format and the text/event-stream content type.
Each message is a line beginning with data: followed by a payload, terminated by a double newline (\n\n). You can serialize JSON after data:.
Content-Type: text/event-streamCache-Control: no-cacheso proxies do not buffer.Connection: keep-aliveon the Node runtime.
// app/api/events/route.ts
export async function GET(): Promise<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const status of ["queued", "running", "done"]) {
const payload = JSON.stringify({ status });
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
await new Promise((r) => setTimeout(r, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Streaming Progress Logs
Long-running jobs (imports, builds, batch processing) benefit from streaming a progress log. Each completed step is enqueued so the client can update a live console without polling.
The pattern is identical: do work, enqueue a line, repeat. Below is a runnable simulation of a multi-step job emitting NDJSON (one JSON object per line).
const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const steps = ["fetch", "transform", "upload"];
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < steps.length; i++) {
await sleep(30);
const line = JSON.stringify({ step: steps[i], pct: ((i + 1) / steps.length) * 100 });
controller.enqueue(encoder.encode(line + "\n"));
}
controller.close();
},
});
const reader = stream.getReader();
const decoder = new TextDecoder();
let buf = "";
let r = await reader.read();
while (!r.done) {
buf += decoder.decode(r.value);
r = await reader.read();
}
for (const l of buf.trim().split("\n")) console.log(JSON.parse(l).step);Handling Client Disconnects
If the user closes the tab mid-stream, you should stop doing work. The Request carries an AbortSignal on req.signal that fires when the connection drops.
Check req.signal.aborted inside your loop, and optionally use the stream's cancel() callback to release resources (close an LLM connection, abort a DB cursor).
// app/api/long/route.ts
export async function GET(req: Request): Promise<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 100; i++) {
if (req.signal.aborted) break; // client left
controller.enqueue(encoder.encode(`tick ${i}\n`));
await new Promise((r) => setTimeout(r, 200));
}
controller.close();
},
cancel(reason) {
console.log("stream cancelled:", reason);
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}Error Handling Inside a Stream
Once you have returned the Response, the HTTP status is already 200 and headers are sent. You cannot switch to a 500 mid-stream.
So wrap risky work in try/catch and surface failures as a chunk (e.g. an SSE event: error line or a JSON error object), then close. Use controller.error(e) only when you want to abruptly tear down the stream.
// app/api/job/route.ts
export async function GET(): Promise<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const data = await riskyWork();
controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"));
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
controller.enqueue(encoder.encode(JSON.stringify({ error: msg }) + "\n"));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: { "Content-Type": "application/x-ndjson" },
});
}Edge Runtime and Backpressure
Streaming shines on the Edge runtime. Opt in with export const runtime = "edge". The Edge runtime is built on Web Streams, so the same ReadableStream code works unchanged and starts flushing instantly from a location near the user.
Backpressure: if the client reads slowly, controller.enqueue still buffers. For high-volume producers, prefer a pull-based source or check controller.desiredSize to pace yourself and avoid unbounded memory growth.
// app/api/edge-stream/route.ts
export const runtime = "edge";
export async function GET(): Promise<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
// desiredSize < 0 means the consumer is behind
if ((controller.desiredSize ?? 1) > 0) {
controller.enqueue(encoder.encode(`edge ${i}\n`));
}
await new Promise((r) => setTimeout(r, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}Consuming the Stream on the Client
On the browser side, fetch gives you response.body, which is itself a ReadableStream. Read it with a reader and decode chunks as they arrive to update the UI progressively.
For SSE specifically you can instead use the native EventSource API. For raw text or NDJSON, the reader loop below is the universal approach.
// components/StreamReader.ts
export async function readStream(url: string, onChunk: (text: string) => void) {
const res = await fetch(url);
if (!res.body) throw new Error("No response body to stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
onChunk(decoder.decode(value, { stream: true }));
}
}Quick Check
Test your understanding of streaming Route Handlers.
Recap
You learned how to stream incremental data from Next.js 15 Route Handlers:
- ReadableStream with
start(controller)pluscontroller.enqueue()/controller.close()is the core primitive; return it inside aResponse. - An async start lets you await between chunks for AI tokens, progress logs, and SSE events.
- Use
text/event-stream+data: ...\n\nfor SSE, or NDJSON for line-delimited JSON. - Watch
req.signal.abortedfor disconnects and clean up incancel(). - Status and headers are fixed once you return, so report mid-stream errors as chunks.
- The Edge runtime (
runtime = "edge") runs the same Web Streams code; mind backpressure viadesiredSize.
자주 묻는 질문
“처리기의 스트리밍 응답과 ReadableStream” 강의는 무료인가요?
네 — “처리기의 스트리밍 응답과 ReadableStream” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
“처리기의 스트리밍 응답과 ReadableStream”에서 뭘 배우나요?
인공지능 토큰, 로그 및 점진적 페이로드를 위해 ReadableStream으로 증분 데이터를 반환하십시오. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.
“처리기의 스트리밍 응답과 ReadableStream” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- Web 요청 API로 RESTful 경로 처리기 설계
- Node 런타임과 Edge 런타임의 절충점
- 처리기의 스트리밍 응답과 ReadableStream
- Zod를 활용한 요청 검증과 타입 지정 JSON 응답