Streaming and live channels
How a server pushes live updates to the browser over HTTP, from streamed bytes to Server-Sent Events and the polling-SSE-WebSockets decision.
Three features land on your desk the same week. A CSV export takes twelve seconds to build on the server, and the product wants a progress bar that actually moves. A notifications panel should light up the instant something happens, not five seconds later when the next poll fires. An LLM endpoint streams its answer one token at a time, the way every chat UI now does.
The fetch you built last lesson resists all three. A fetch is a request-response: you send one request, wait, read one buffered answer, and the round-trip closes. None of these is a round-trip. Each is a server-to-client live update over HTTP, where the connection stays open and the server keeps writing: the export pushes progress as it computes, the notifications channel whenever there’s news, the LLM tokens as it generates them. The answer arrives in pieces, or it never stops arriving.
That shape raises new questions. What primitive carries the bytes? What format rides the wire? How does the client read a response that isn’t finished? And the one that decides your architecture: when does polling beat a live stream, and a live stream beat a WebSocket?
The substrate was there all along. A Response body is a stream of bytes, and last lesson’s .json() and .text() hid that by reading it to the end. Pull the stream yourself and you read chunks as they land. On top of it sits Server-Sent Events, not a new transport but a thin text convention over that same stream, which you’ll read by hand before reaching for the browser API that does it for you.
Response.body is a stream you can pull
Section titled “Response.body is a stream you can pull”Last lesson you read every response body with a consumer like response.json() or response.text(). Those read the body to completion and only then resolve with the whole thing. That’s buffering, and for a two-kilobyte JSON row it’s exactly right: the body is tiny, and you want all of it before you act. Aim that same .json() at a twelve-second export and it works against you: the promise won’t resolve until the last byte arrives, so you get twelve seconds of blank screen, then everything at once. You wanted the first byte now.
Underneath the consumer sits a stream you can read yourself. response.body is a ReadableStream , a pull-based stream of chunks , where each chunk is a Uint8Array of raw bytes. Reach for it instead of the consumer and the stream is yours to pull one chunk at a time.
In 2026 you pull a stream with a for await loop:
const response = await fetch('/api/export');
for await (const chunk of response.body) { // chunk is a Uint8Array — one network frame, just arrived}A ReadableStream is async-iterable on every server runtime this course touches: Node, the Edge runtime, and the renderer behind a Server Component. In browsers it’s only just universal: Safari was the last holdout and shipped it in version 26.4, so global support sits around three-quarters. On the server, or in a browser app that needn’t support older Safari, for await reads the stream directly. Each turn hands you the next slice of the body that has physically arrived. You act on chunk one while chunk four is still in flight, and that head start is exactly what buffering took away. To cover older Safari, use the getReader() loop below, the cross-browser spelling of the same read.
The sequence below puts the two approaches side by side over time. Scrub through it.
Buffering with .json(). The body fills completely before you receive anything. For a small JSON row that’s instant. For a twelve-second export it’s twelve seconds of nothing, then everything.
Pulling with response.body. Each chunk reaches you the moment it lands. You act on chunk one while chunk four is still on the wire, so the progress bar moves as soon as chunk one arrives.
A chunk is a network frame, not a message. The network split these bytes wherever it pleased, so one message can span two chunks and one chunk can hold pieces of two messages. Hold that thought; it becomes the problem two sections from now.
A chunk’s size is decided by the network, by packet sizes, buffering, and timing, never by your data, so it almost never lines up with a unit of meaning. Hold on to that: a chunk is a network frame, not a message.
You’ll meet another spelling in existing code, inside libraries, and anywhere older Safari is still in scope. Instead of for await, you call response.body.getReader() and loop on reader.read(), which hands back { done, value } each turn. Same read with more ceremony: you also call reader.releaseLock() when you bail early. Reach for for await when you can, and drop to getReader() when your support matrix still includes Safari before 26.4.
The getReader() loop, for reference and for older Safari
const reader = response.body.getReader();
while (true) { const { done, value } = await reader.read(); if (done) break; // value is the Uint8Array chunk}
reader.releaseLock();for await does all of this for you, including releasing the lock when the loop ends. Prefer it, and reach for this form when you recognize it in the wild or must support Safari before 26.4.
Decoding chunks back to text
Section titled “Decoding chunks back to text”Each chunk arrives as a Uint8Array of raw bytes, but what you want is text. Bridging the two takes one detail that, if you miss it, ships a bug your tests won’t catch.
The bridge is a pair of platform objects. TextEncoder goes from string to bytes: new TextEncoder().encode(s) returns the UTF-8 bytes of a string. TextDecoder goes the other way: new TextDecoder().decode(bytes) returns the string those bytes spell. You use the decoder now to turn chunks into text, the encoder later when the server writes bytes onto a stream.
UTF-8 encodes most characters in a single byte, but anything beyond plain ASCII, such as an accented é, an emoji, or a CJK character, takes two to four. The network doesn’t care where a character’s bytes begin and end, so it can cut a chunk in the middle of one: the first two bytes of a three-byte € ride one chunk, the last byte rides the next. Decode that first chunk on its own and the decoder sees two bytes that don’t spell a complete character, gives up, and emits the replacement character �. The euro sign is gone, corrupted on a boundary the network chose at random.
The fix is a single option:
const decoder = new TextDecoder();
for await (const chunk of response.body) { const text = decoder.decode(chunk, { stream: true }); // text is safe — a split character is held back, not corrupted}
const tail = decoder.decode(); // flush any held-back bytes{ stream: true } tells the decoder it’s mid-stream: when a chunk ends on an incomplete multi-byte sequence, it holds those trailing bytes back and prepends them to the next chunk, reassembling the split € on the call that receives its final byte. Because it carries that held-back state between calls, you create it once above the loop and reuse the same instance every iteration. The final bare decoder.decode() flushes anything still buffered, in case the stream’s last character was split. The rule: always { stream: true } on chunks, one reused decoder, one final flush.
Predict what the broken version prints. The stream below delivers "café" in two chunks, and the é, which is two bytes in UTF-8, is split across the boundary. The decoder is called per chunk with no stream option.
Each chunk is decoded with `new TextDecoder().decode(chunk)` — no stream option. What gets logged? Predict what this program prints, then press Check.
// chunkA = bytes for "caf" + the FIRST byte of "é"// chunkB = the SECOND byte of "é"for (const chunk of [chunkA, chunkB]) { console.log(new TextDecoder().decode(chunk));}é — an incomplete sequence. Without { stream: true }, the decoder can’t hold it back, so it emits caf plus a � for the orphaned byte. The second chunk is the other half of é by itself — also incomplete on its own — so it decodes to a second �. The character is lost in both directions. { stream: true } plus a reused decoder would have held the byte back and printed café on the second call.From chunks to messages
Section titled “From chunks to messages”The decoder gives you correct text, but chunks are still network frames while your application thinks in messages. One message can span two chunks; two messages can land in one. One chunk never reliably equals one message.
So how do you read whole messages off a stream that hands you arbitrary fragments? One pattern works for any framed protocol: accumulate the decoded text into a buffer; split it on the protocol’s framing token; keep the final, possibly-incomplete fragment as the tail for the next iteration; emit everything before it. For the SSE ahead, the framing token is the blank line, the two newlines \n\n that end one event.
Skip the tail and you corrupt every event that crosses a boundary. Say you split each chunk on its own on \n\n, and a chunk arrives carrying event1\n\nevent2\npart: one complete event and the start of a second. Splitting gives ["event1", "event2\npart"]. Emitting event1 is fine, but you then emit event2\npart as if whole, when the rest of event two is still coming in the next chunk.
Buffer instead and the boundary stops mattering. Watch the buffer and its tail move across two chunks.
Chunk A arrives. The buffer now holds event1\n\nevent2\npart. Split on \n\n: everything before the last separator is complete, so emit event1. The trailing event2\npart has no terminator yet, so keep it as the tail.
Chunk B arrives. Prepend the kept tail, and event2\npart plus rest reunite into a whole event. Now the buffer splits cleanly into two complete events, so emit event2 and event3. The buffer ends exactly on \n\n, so the new tail is empty. Nothing was lost on the boundary.
That’s the whole move: append, split, keep the tail, parse the rest. The tail is the fiddly part, a fragment you deliberately hold back until its other half arrives. Here are the two versions side by side.
for await (const chunk of response.body) { const text = decoder.decode(chunk, { stream: true }); for (const event of text.split('\n\n')) { handle(event); }}Splits each chunk in isolation. An event split across two chunks emits as two half-events: the trailing fragment is treated as complete, and its continuation in the next chunk is never joined to it.
let buffer = '';
for await (const chunk of response.body) { buffer += decoder.decode(chunk, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop() ?? ''; for (const event of events) { handle(event); }}Appends, then keeps the tail. Everything before the last \n\n is complete and gets handled; events.pop() lifts off the final fragment and stores it in buffer as the tail, which the next chunk appends onto, reuniting any split event.
Notice that nothing here looked inside an event; it only cared where one ends. Splitting bytes into whole messages and understanding what a message says are two separate jobs. What those bytes mean is the next section’s problem.
The Server-Sent Events wire format
Section titled “The Server-Sent Events wire format”Server-Sent Events (SSE), the format your notifications panel and your LLM token stream both want, is not a new transport or a library. It’s a convention: an ordinary HTTP response with the content type text/event-stream that stays open while the server writes lines of UTF-8 text down it. The client reads that stream exactly the way you just learned and dispatches one event per blank-line-terminated block. Being plain text over a normal HTTP response, it rides everything HTTP already gives you: CDNs and proxies, HTTP/2 multiplexing, and your app’s auth cookies, with nothing extra to stand up.
The text follows a tiny grammar: each event is one or more key: value lines, ended by a blank line. The block below is a literal slice of an SSE stream. Hover the fields to see what each one does.
data: {"progress":10}
event: notificationid: 42data: {"title":"Invoice paid"}
retry: 3000data: {"progress":20}The first event is bare: a data: line with a JSON payload, then a blank line to terminate it. With no event: field, the client receives it as the default type, message. The second is named with event: notification, so a client can listen for notification events specifically. It also carries id: 42, and that id is what makes SSE resilient: the browser remembers the last id it saw, and if the connection drops it sends that id back in a Last-Event-ID header on reconnect, so the server resumes from event 43 instead of replaying from the top. The retry: field hints how long to wait before reconnecting; you’ll rarely set it.
One rule governs the data: payload. Each data: line is one logical line: a literal newline inside the payload starts a second data: line, and a blank line terminates the event early. So you never write a raw multi-line string, you stringify:
const frame = `data: ${JSON.stringify(payload)}\n\n`;JSON.stringify escapes any newline in the payload to \n inside the string, collapsing it to a single safe line, and you add the \n\n terminator yourself. The pattern: one JSON.stringify’d data: line per event, terminated by a blank line. Skip the stringify and a payload with a newline in it breaks the framing.
Emitting an SSE stream from a Route Handler
Section titled “Emitting an SSE stream from a Route Handler”Now the server side. In Next.js, a streaming response lives in a Route Handler: a file at app/api/<path>/route.ts that exports one function per HTTP method. The handler returns a Response, and for SSE that Response wraps a ReadableStream you write events into.
The handler builds a new ReadableStream whose start(controller) function does the writing: controller.enqueue(...) pushes a chunk, controller.close() ends the stream. A ReadableStream carries bytes, not strings, so every SSE frame goes through TextEncoder on the way out, the exact inverse of the client.
The handler below streams a few progress updates, closes, and cleans up if the client leaves first.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}A GET taking the incoming request. Its request.signal is the hook for cleanup at the end: an AbortSignal the runtime fires the moment the client disconnects. It’s the same AbortSignal you passed into fetch last lesson, now handed to you on the receiving side.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}One TextEncoder for the whole stream, the string-to-bytes bridge that mirrors the decoder on the client.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}new ReadableStream with a start(controller) function. start runs once when the stream is read; the controller is your handle for writing into it: enqueue to push a chunk, close to end it.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}The write itself: build one SSE frame from data:, the JSON-stringified payload, and the \n\n terminator, encode it to bytes, and enqueue it. The one-line-per-event rule from the wire-format section, now firing once a second.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}Cleanup on disconnect. Subscribe to request.signal’s abort event, so that when the client closes the tab you clear the interval and close the controller. Skip this and every client who navigates away leaves a setInterval running forever on your server, plus an open database cursor in a real handler.
export async function GET(request: Request) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { let progress = 0; const timer = setInterval(() => { progress += 10; const frame = `data: ${JSON.stringify({ progress })}\n\n`; controller.enqueue(encoder.encode(frame)); if (progress >= 100) { clearInterval(timer); controller.close(); } }, 1_000);
request.signal.addEventListener('abort', () => { clearInterval(timer); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }, });}Return the stream as a Response with three headers. Content-Type: text/event-stream is the protocol opt-in that browsers and proxies key on. Connection: keep-alive matters only on HTTP/1.1, since HTTP/2 and 3 ignore it. Cache-Control: no-cache, no-transform carries the load-bearing token.
That no-transform is the single most common way SSE breaks only in production. A CDN or proxy between your server and the user is allowed, by default, to buffer a response: hold the bytes and optimize delivery. Do that to a stream and you’ve destroyed its point: the proxy collects every event, waits for the stream to end, and delivers one twelve-second-late blob. It worked on localhost because no proxy sat in the way. no-transform tells every middlebox to pass these bytes through untouched.
One more guard the code implies: once controller.close() has run, here or in the abort handler, calling enqueue again throws. So a longer-lived handler guards every write behind an “is this still open” check, the way the abort listener stops the timer first.
Consuming SSE with EventSource
Section titled “Consuming SSE with EventSource”You read one SSE stream by hand to prove it’s just framed text. Now meet the browser API that does the framing for you: EventSource . Give it a URL and it opens the connection, parses the data:/event:/id: grammar, and dispatches one clean event per message, no buffer-and-tail loop to write.
The surface is small. new EventSource(url) opens the connection. source.onmessage fires for default events, with the raw payload on event.data; source.addEventListener('<name>', ...) fires for events that carried a matching event: field; source.close() shuts it down. The payload is still wire data, so the habit from last lesson holds: JSON.parse it and validate the shape before you trust it, even when the wire is your own server.
What makes EventSource the default is that it auto-reconnects: if the connection drops, the browser reopens it and sends back the last id: it saw in the Last-Event-ID header, so a server that numbers its events resumes where it left off.
Inside React, the lifecycle has one correct home. The subscription is a side effect with a setup and a teardown, so it lives in a useEffect, and the cleanup closes the connection, the same discipline as the AbortSignal from last lesson.
useEffect(() => { const source = new EventSource('/api/notifications');
source.onmessage = (event) => { const data = notificationSchema.parse(JSON.parse(event.data)); addNotification(data); };
source.addEventListener('ping', () => { setLastSeen(Date.now()); });
return () => source.close();}, []);Open the connection inside the effect. new EventSource(url) starts reading immediately and auto-reconnects on its own if the stream drops.
useEffect(() => { const source = new EventSource('/api/notifications');
source.onmessage = (event) => { const data = notificationSchema.parse(JSON.parse(event.data)); addNotification(data); };
source.addEventListener('ping', () => { setLastSeen(Date.now()); });
return () => source.close();}, []);onmessage handles the default events. event.data is the raw string, so JSON.parse it and run it through a schema; the parsed-and-validated value is the only one you trust.
useEffect(() => { const source = new EventSource('/api/notifications');
source.onmessage = (event) => { const data = notificationSchema.parse(JSON.parse(event.data)); addNotification(data); };
source.addEventListener('ping', () => { setLastSeen(Date.now()); });
return () => source.close();}, []);addEventListener('ping', ...) handles a named event, one the server tagged with event: ping. Named and default events ride the same connection; you listen for each separately.
useEffect(() => { const source = new EventSource('/api/notifications');
source.onmessage = (event) => { const data = notificationSchema.parse(JSON.parse(event.data)); addNotification(data); };
source.addEventListener('ping', () => { setLastSeen(Date.now()); });
return () => source.close();}, []);The cleanup calls source.close(), non-optional: an EventSource left open after the component unmounts keeps the connection alive and burns the tab’s per-origin connection budget. Closing on teardown is why the subscription belongs in an effect.
useEffect is the right home for this today. A future React pattern for reading async sources directly in render lands later in the course and replaces this lifecycle in some cases. For now: open in the effect, close() in the cleanup.
EventSource limits and the fetch fallback
Section titled “EventSource limits and the fetch fallback”EventSource is the default, but three limits are baked into the API:
- It can’t set custom request headers, so no
Authorizationand no CSRF token. - It can only issue a
GET. - It can’t carry a request body.
Authentication is the limit that bites, and for the common case it has a clean answer. Browser SSE to your own server authenticates with cookies, which ride on an EventSource request automatically (add withCredentials: true for the cross-origin case), so cookie-authenticated SSE works out of the box. Bearer-token auth is what breaks: you can’t attach an Authorization header, so a stream behind a bearer token can’t use the browser API at all.
When a limit stops you, the response is still a text/event-stream, the same byte protocol, so you consume it with plain fetch instead. Set whatever headers you need, read response.body, and reframe the events with the append-split-keep-tail loop from two sections ago. The framing token is still \n\n.
// Cookie-authed SSE — cookies ride along automatically.const source = new EventSource('/api/notifications');source.onmessage = (event) => handle(JSON.parse(event.data));Reach for this first. Zero parsing code, and you get auto-reconnect with Last-Event-ID replay for free. The default for any server-to-client stream your app authenticates with cookies.
const response = await fetch('/api/notifications', { headers: { Authorization: `Bearer ${token}` },});
let buffer = '';for await (const chunk of response.body) { buffer += decoder.decode(chunk, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop() ?? ''; for (const event of events) { handle(event); }}Drop to this only when a hard limit stops you: a bearer token, a non-GET, or a request body. Same protocol, but you reframe events by hand and lose the automatic reconnect and Last-Event-ID replay, which you’d have to reimplement. That cost is why EventSource stays the default. (This runs in the browser, so if your support matrix still includes Safari before 26.4, swap the for await for a getReader() loop.)
Choosing the channel: polling, SSE, or WebSockets
Section titled “Choosing the channel: polling, SSE, or WebSockets”You can now read a stream, emit one, and consume one. The last piece is a decision, not a technique: of the three ways to push updates to a client, which does this feature need? The trap is reaching for the most powerful one first. Each option costs more than the last, so you ask two questions in a fixed order and let the answer fall out. First, direction: can the client only receive, or must it also send? That picks the transport before anything else does. Second, freshness: how fresh must the data be against how often it changes? Walk the tree, and the three options sort themselves out: polling as the default, SSE as the reach past it, WebSockets only for a two-way channel.
The default, and where most “real-time” features should stay. A normal request-response call on an interval (setInterval, or a polling option on a data-fetching library you’ll meet later). No open connection, no new infrastructure. Leave it only when the freshness need outpaces a sustainable cadence, or the fan-out (clients times cadence) becomes a load problem of its own.
Server-to-client, JSON or text, with timely pushes. SSE isn’t a new transport: it rides existing HTTP infrastructure, stays CDN-friendly with no-transform, reuses your cookie auth, and EventSource gives you auto-reconnect for free. Export progress, notifications, LLM token streams, live status.
The only thing that earns them is a bidirectional channel: the client sends on the same live connection it receives on. Collaborative cursors, chat with typing indicators, multiplayer state. A separate connection model with no HTTP cache and its own auth handshake — a deliberate reach, not a default. The API is out of scope for this course; just recognize the trigger.
The value is the order. Ask “can the client only receive?” first and most features answer yes, never getting near a WebSocket; ask “how fresh?” second and most of those stay on polling. The powerful tools sit at the end of the funnel on purpose.
What SSE is not for
Section titled “What SSE is not for”Two jobs look like streaming but aren’t.
External resources
Section titled “External resources”The platform reference for response.body — reading a stream with for await, the getReader loop, and releasing the lock.
The SSE protocol and the EventSource API end to end — the field grammar, named events, reconnection, and Last-Event-ID.
The decode method and the { stream: true } option that holds back a split multi-byte character across chunk boundaries.
The official guide to streaming a Response from a Route Handler — the ReadableStream and TextEncoder shape, plus the proxy and CDN buffering that breaks streams in production.