Skip to content
Chapter 15Lesson 2

Streaming and live channels

How a server pushes live updates to the browser over HTTP, from raw streamed bytes up to Server-Sent Events and the polling-SSE-WebSockets decision.

Three features land on your desk in 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. And an LLM endpoint streams its answer one token at a time, the way every chat UI now does.

Reach for the fetch call you built in the last lesson and all three resist it. The call isn’t wrong; it just isn’t the shape these features need. A hardened fetch is a request-response: you send one request, you wait, you read one buffered answer, and the round-trip closes. None of these three is a round-trip. Each one 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 pushes whenever there’s news. The LLM pushes tokens as it generates them. The answer arrives in pieces, or it never stops arriving.

That reframe is what this lesson is about. You already know request-response. What’s new is this open-ended shape, and it raises a few questions. What primitive carries the bytes? What format rides the wire? How does the client read a response that isn’t finished? The decision that matters most is when polling beats a live stream, and when a live stream beats a WebSocket.

We’ll build this in two halves, and the second is made of the first. The first half is the substrate: a Response body was always a stream of bytes, and the .json() and .text() from last lesson hid that by reading it to the end. Pull the stream yourself instead and you read chunks as they land. The second half is the channel: Server-Sent Events, which turns out not to be a new transport at all, just a thin text convention layered on that same stream. You’ll read one SSE stream by hand, to see for yourself that it’s only framed text over response.body, before you reach for the browser API that does it for you.

The first move is to stop letting .json() read the body for you. The body was a stream all along.

Last lesson, you read every response body with a consumer like response.json() or response.text(). Look closely at what those do: they read the body to completion, every byte from start to finish, and only then resolve with the whole thing. That’s buffering. For a two-kilobyte JSON row it’s exactly right, because the body is tiny and you want all of it before you do anything. But aim that same .json() at a twelve-second export and it works against you. The promise won’t resolve until the last byte of the last row arrives: twelve seconds of a blank screen, then everything at once. You wanted the first byte now.

Underneath that 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. The consumer methods were reading that stream for you and handing back the assembled result. Skip them, reach for response.body directly, and the stream is yours to pull one chunk at a time.

In 2026 the way you pull a stream is 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 the server runtimes this course touches: Node, the Edge runtime, and the renderer behind a Server Component. In browsers it’s only just become universal. Safari was the last holdout and didn’t ship it until version 26.4, so global support sits around three-quarters. On the server, or in a browser app that doesn’t have to support older Safari, for await reads the stream directly. Each turn of the loop hands you exactly one chunk: the next slice of the body that has physically arrived over the network. You act on chunk one while chunk four is still in flight, and that head start is exactly what buffering took away. When you do have to cover older Safari, reach for the getReader() loop below, which is the cross-browser-safe spelling of the same read.

The sequence below puts the two approaches side by side over time. Scrub through it.

response.json()
entire body — read to completion
consumer the whole body, at the end
entire body

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.

response.body
chunk 1
chunk 2
chunk 3
chunk 4 — on the wire
consumer one chunk at a time
chunk 1chunk 2chunk 3

Pulling with response.body. Each chunk reaches the consumer the moment it lands. You act on chunk one while chunk four is still on the wire, so the progress bar can move as soon as chunk one arrives.

response.body
message A
message B
chunk 1
chunk 2
chunk 3
chunk 4 — on the wire
consumer one chunk at a time
chunk 1chunk 2chunk 3

A chunk is a network frame, not a message. The network split these bytes wherever it pleased, so a single 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.

That last frame raises a problem we’ll return to two sections from now. A chunk’s size is decided by the network, by packet sizes, buffering, and timing, not by your data. So a chunk almost never lines up with a unit of meaning. For now, just hold on to the one idea: a chunk is a network frame, not a message.

There’s another spelling you’ll meet 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. It’s the same stream read the same way, with more ceremony: you also have to remember to call reader.releaseLock() when you bail early. But it works in every browser regardless of version, so it’s not merely legacy code. It’s the safe cross-browser choice when your support matrix still includes Safari before 26.4. Reach for for await when you can, and drop to getReader() when you must.

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 where you can, and reach for this form when you recognize it in the wild or when your browser support matrix still includes Safari before 26.4.

You’re holding Uint8Array chunks of raw bytes, and what you almost always want is text. So you need the bridge between the two. You also need one detail that, if you miss it, ships a bug no test you write will 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. We’ll use the decoder now to turn chunks into text, and the encoder later when the server writes bytes onto a stream. They’re the only two byte primitives this lesson needs.

Here is that detail. 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, three, or four bytes. And the network doesn’t know or care where a character’s bytes begin and end. It can cut a chunk in the middle of a multi-byte character: the first two bytes of a three-byte ride one chunk, and 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 chunk 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 instead of guessing, then prepends them to the front of the next chunk. The split gets reassembled on the call that receives its final byte. The decoder carries that held-back state between calls, which is why you create it once, above the loop, and reuse the same instance every iteration. A fresh decoder per chunk would have no memory of the bytes it set aside. The final bare decoder.decode() with no argument flushes anything still buffered at the end, in case the stream’s very last character was split. So the rule is: 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));
}

The decoder gives you correct text. It does nothing about the problem the diagram flagged: chunks are network frames, and your application thinks in messages. One message can span two chunks. Two messages can land in one chunk. You cannot assume one chunk equals one message, because the network never promised that and routinely breaks it.

So how do you read whole messages off a stream that hands you arbitrary fragments? There’s one pattern, and it works for any framed protocol you’ll ever read off a byte stream. It’s the direct prerequisite for parsing SSE by hand in a moment. Here it is, named once so you recognize it later: accumulate the decoded text into a buffer; split the buffer on the protocol’s framing token; the final fragment might be incomplete, so keep it as the tail and carry it into the next iteration; then emit everything before the tail. For the SSE we’re about to meet, the framing token is the blank line, the two newlines \n\n that mark the end of one event.

The naive version skips the tail, and it’s worth seeing it fail before you trust the fix. Picture splitting each chunk on its own on \n\n. A chunk arrives carrying event1\n\nevent2\npart, which is one complete event and the start of a second. Split it and you get ["event1", "event2\npart"]. You emit event1, which is fine, but then you also emit event2\npart, a half-event with no terminator yet, as if it were whole. The rest of event two is still coming in the next chunk, and you’ve already mangled it. Every event that spans a boundary is corrupted.

Buffer instead, and the boundary stops mattering. Watch the buffer and its tail move across two chunks.

incoming chunk
event1\n\nevent2\npart
buffer
event1\n\nevent2\npart tail event2\npart
emitted
event1

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.

incoming chunk
rest\n\nevent3\n\n
buffer
event2\npartrest\n\nevent3\n\n tail ""
emitted
event2event3

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 entire move: append, split, keep the tail, parse the rest. The tail is the one genuinely fiddly part. It’s a fragment you’re deliberately not emitting yet, held until its other half arrives. Once that one piece is right, the rest of framed-stream parsing is straightforward. 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 gets emitted as two half-events: the trailing fragment of one chunk is treated as complete, and its continuation in the next chunk is never joined to it. Any event that straddles a boundary is corrupted.

Notice that this section never looked inside an event; it only cares about where one ends. Splitting bytes into whole messages and understanding what a message says are two separate jobs. The framing token is \n\n, and what those bytes mean is the next section’s problem. Keeping the two jobs apart is what keeps each piece simple.

Pulling streams by hand pays off here. Server-Sent Events (SSE), the thing your notifications panel and your LLM token stream both want, is not a new transport, not a binary protocol, and not a library. It’s a convention: an ordinary HTTP response with the content type text/event-stream that stays open, and the server writes lines of UTF-8 text down it. The client reads the stream exactly the way you just learned and dispatches one event per blank-line-terminated block. That’s the entire idea. Because it’s just text over a normal HTTP response, it rides every piece of infrastructure HTTP already has: it works through CDNs and proxies, it multiplexes over HTTP/2, and it carries your app’s cookies for authentication with nothing extra. There’s no new wire to stand up.

The text follows a tiny grammar. Each event is one or more key: value lines, and a blank line ends it. The block below is a literal slice of an SSE stream, every byte the server actually sent. Hover the fields to see what each one does.

data: {"progress":10}
event: notification
id: 42
data: {"title":"Invoice paid"}
retry: 3000
data: {"progress":20}

Read it top to bottom. 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 event is named, with event: notification, so a client can listen for notification events specifically rather than lumping everything into the default. It also carries id: 42. That id: is the quiet workhorse of SSE: 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 can resume from event 43 instead of replaying from the top. You get that resilience for the cost of numbering your events. The retry: field hints how long the browser should wait before reconnecting; recognize it, though you’ll rarely set it.

One rule governs writing the data: payload, and breaking it corrupts the stream. Each data: line is one logical line: a literal newline inside the payload starts what the protocol reads as a second data: line, and a blank line inside it 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. So the pattern is: one JSON.stringify’d data: line per event, terminated by a blank line. Send an un-stringified payload with a newline in it and the reader either sees two data: lines or hits a premature terminator, so the framing breaks on data you didn’t sanitize.

Emitting an SSE stream from a Route Handler

Section titled “Emitting an SSE stream from a Route Handler”

Now you write the server side. In Next.js, a streaming response lives in a Route Handler, a file at app/api/<path>/route.ts that exports a 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 onto the stream, and controller.close() ends it. Here the encoder from earlier earns its place, because a ReadableStream carries bytes, not strings, so every SSE frame goes through TextEncoder on its way out. It’s the exact inverse of the decoding you did on the client: the server encodes text to bytes to send, and the client decodes bytes to text to read.

Step through the handler below. It streams a few progress updates, then closes, and it 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',
},
});
}

The handler is a GET that takes the incoming request. That request.signal is the hook we’ll use at the end: it’s 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',
},
});
}

Create one TextEncoder for the whole stream. Everything you enqueue must be bytes, and this is the string-to-bytes bridge, the mirror of 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, and 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',
},
});
}

This is the write itself. Build one SSE frame from data:, the JSON-stringified payload, and the \n\n terminator, then encode it to bytes and enqueue it. It’s 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',
},
});
}

Here is the 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, and in a real handler an open database cursor too. That’s one leaked timer per lost 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',
},
});
}

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. And Cache-Control: no-cache, no-transform carries the load-bearing token.

1 / 1

That no-transform deserves its own paragraph, because forgetting it is the single most common way SSE breaks only in production. A CDN or proxy sitting 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 the entire point. The proxy collects all your events, waits for the stream to end, and delivers one twelve-second-late blob. It worked perfectly on localhost because there was no proxy in the way. no-transform is the explicit instruction to every middlebox to not buffer, not touch, and pass these bytes through as they arrive.

There’s one more guard the code implies that’s worth stating outright. Once you’ve called controller.close(), or once the abort handler has, calling enqueue again throws. So in a longer-lived handler you guard every write behind an “is this still open” check, the same way the abort listener stops the timer before anything else can fire.

Order the construction. The handler is fixed above the steps, so drag the build order into place.

Put the steps of building an SSE Route Handler in the order they run. Drag the items into the correct order, then press Check.

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',
},
});
}
Create the TextEncoder — the stream carries bytes, not strings
Construct the ReadableStream with a start(controller) function
Enqueue each event as an encoded data: frame
Subscribe to request.signal to clean up on client disconnect
Return a Response with the three SSE headers

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, the one you’ll reach for most of the time you consume SSE: EventSource . You give it a URL, and it opens the connection, reads the stream, parses the data:/event:/id: grammar, and dispatches a clean event object per message. There’s no buffer-and-tail loop to write, because it owns that internally.

The surface is small. new EventSource(url) opens it. source.onmessage fires for default (unnamed) events, with the raw payload string on event.data. source.addEventListener('<name>', ...) fires for events that carried a matching event: field. source.close() shuts it down. And the payload is still wire data, so the habit from last lesson holds: you JSON.parse it and validate the shape before you trust it. Never trust the wire, even when the wire is your own server.

The win that makes EventSource the default is that it auto-reconnects. If the connection drops, the browser reopens it on its own and sends back the last id: it saw in the Last-Event-ID header, so a server that numbers its events can resume exactly where it left off. You get resilient streaming for free.

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 function closes the connection. It’s the same setup-then-teardown discipline as the AbortSignal from last lesson. Step through it:

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 will auto-reconnect 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 the server sent, so you JSON.parse it and run it through a schema before using it. 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 just 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 function calls source.close(). This is non-optional: an EventSource left open after the component unmounts keeps the connection alive and quietly burns the tab’s per-origin connection budget. Closing on teardown is the whole reason the subscription belongs in an effect.

1 / 1

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 when you reach it, you’ll recognize this as the lifecycle it replaces in some cases. For now, the rule is simple: open in the effect, close() in the cleanup.

When EventSource runs out and plain fetch takes over

Section titled “When EventSource runs out and plain fetch takes over”

EventSource is the default, but it has a hard ceiling, and one common requirement walks straight into it. There are three limits, all baked into the API:

  • It can’t set custom request headers, so no Authorization and no CSRF token.
  • It can only issue a GET.
  • It can’t carry a request body.

Most of the time none of that gets in your way, because the limit that matters most, authentication, has a clean answer for the common case. Browser SSE to your own server authenticates with cookies, and cookies travel on an EventSource request automatically (with withCredentials: true for the cross-origin case). So cookie-authenticated SSE works with EventSource out of the box. It’s bearer-token auth that breaks it: there’s no way to attach an Authorization header to an EventSource, so a stream behind a bearer token can’t use the browser API at all.

When one of these limits does stop you, you already know the escape hatch, because you built it. The response is still a text/event-stream, the exact same SSE byte protocol. So you consume it with plain fetch instead: set whatever headers you need, then read response.body and reframe the events yourself with the append-split-keep-tail loop from two sections ago. That loop wasn’t an academic exercise; it’s the thing you reach for the moment EventSource can’t carry your auth. SSE is just framed text, and the framing token is \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 own app authenticates with cookies.

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 thing, and the one you’ll carry longest, isn’t a technique. It’s a decision: of the three ways to push updates to a client, which does this feature actually need? Juniors reach for the most powerful one first and over-build. An experienced engineer asks the questions in a fixed order and lets the answer fall out, because each option costs more than the last.

Here are the three options, in the order you consider them.

Polling is the default, and it’s the one to start every “real-time” feature on. You make a normal request-response call on an interval (setInterval, or a polling option on a data-fetching library you’ll meet later in the course) and re-fetch on a cadence. There are no open connections and no new infrastructure, and it works through every layer you already have. You leave it when the freshness the product needs is tighter than a cadence your server can sustain, or when the fan-out (clients times cadence) becomes a load problem of its own. The 2026 reality is blunt: most SaaS features that feel real-time are five-to-thirty-second polling, and never need anything more.

SSE is the conditional reach past polling. You take it when the channel is server-to-client only, the payload is JSON or text, and one persistent HTTP connection per subscribed tab is a budget you’re happy to pay. That covers all three of your opening features, the export progress bar, the notifications panel, and the LLM token stream, along with cases like a live order status. Its whole appeal is that it isn’t a new transport. It rides existing HTTP infrastructure, stays CDN-friendly with no-transform, multiplexes over HTTP/2, and reuses your cookie auth.

WebSockets are the conditional reach past SSE, and only one thing takes you there: the channel has to be bidirectional, meaning the client needs to send on the same live connection it receives on. Examples are collaborative cursors, chat with typing indicators and delivery acknowledgements, and a multiplayer canvas. These are a genuinely different transport, with a separate connection model, no HTTP cache, and their own authentication handshake. We name the trigger so you recognize it; the API itself is out of scope for this course. The trigger is simple: the moment a feature needs the client to push over the live channel, SSE is no longer enough.

Walk the decision. Pick a branch at each step.

Which live-update channel does this feature need?

The value is the order: direction before frequency. Ask “can the client only receive?” first, and most features answer “yes” and never get near a WebSocket. Ask “how fresh?” second, and most of those answer “a few seconds is fine” and stay on polling. The powerful tools sit at the end of the funnel on purpose.

Two features look like they belong on a stream and don’t. Naming them now keeps you from reaching for the wrong tool later.