Topic 2.4
Server-Sent Events
In one line
Server-Sent Events (SSE) is a plain-text format for a server to push messages over one open HTTP response: lines like data: …, with a blank line ending each message. Chat apps read it with fetch, because EventSource cannot send a POST body, and they buffer text across network packets because a line can be cut anywhere.
Think of it like this
Telegrams in the old days. Each telegram is a few lines of words ending with the word STOP. The telegrams arrive one after another, and sometimes the wire delivers half a telegram, then the rest a moment later. You do not act on a telegram until you have seen its STOP. And now and then the operator sends a harmless "still here" signal so you know the line is alive. SSE is that: messages ended by a marker (a blank line), which may arrive in pieces, with the occasional "still here".
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Server-Sent Events (SSE)
- A text format for a server to push messages over one open HTTP response.
- Event
- One SSE message: a few
field: valuelines ended by a blank line. - data:
- The field that carries the message content. Several data lines are joined with newlines.
- Comment
- A line starting with
:. Ignored by the parser, used as a keep-alive. - Keep-alive
- A small message sent now and then so an idle connection is not closed by a proxy.
- EventSource
- The browser's built-in SSE reader. GET only, no body, no custom headers.
- Buffer
- A string you keep between pieces, holding text that is not yet a complete event.
- content-type: text/event-stream
- The response header that says the body is SSE.
Step by step
01The format, as raw text
The first live example prints a raw SSE body as one string with its newlines visible, then parses each block. Read the string first: a comment, an event with a name and an id, an event with two data lines, one with no space after the colon, and a retry: block with no data.
Then the output: the comment and the retry block are ignored, the two-line event is joined with a newline, and data:no space works. A body written with Windows line endings parses the same.
02Why the naive version fails
The second example reads a real chat stream through a stand-in server that cuts it into 13-byte packets. The first four packets are printed as they arrive: data: {"id":", then chatcmpl-fake, and so on. No packet is a whole event.
The naive parser expects every packet to be data: {…} and fails 71 times out of 71. The buffered parser reads all 6 events and ends with an empty buffer.
let buffer = "";
for await (const piece of res.body.pipeThrough(new TextDecoderStream())) {
buffer += piece; // 1. add what just arrived
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, end); // 2. a complete event
buffer = buffer.slice(end + 2); // keep only the rest
handle(event);
} // 3. whatever is left waits for the next piece
}03A parser that survives every cut
The third example is a small parser that works line by line: it buffers the unfinished line, applies the field rules, and delivers an event at each blank line. It is fed a body with Windows line endings in pieces of 1, 2, 3, 5, 8 and 13 characters, and the events are identical every time.
Notice two details in its output. The id: 7 sticks: later events carry it too, because the id is the most recent one seen. And the last event never got its blank line, so it is correctly not delivered.
04EventSource, for comparison
EventSource is two lines to use and does the reconnecting for you, and it cannot be used for a chat request. The sandbox in these pages only intercepts fetch, so there is no live EventSource example here; this is the whole API.
const source = new EventSource("/api/notifications"); // GET only: no method, no body, no headers
source.onmessage = (event) => show(event.data);
source.onerror = () => { /* it reconnects by itself, sending Last-Event-ID if the server gave ids */ };
source.close(); // when you are doneTry it yourself
- 1
Cut at different places
In the second example, change
splitBytes: 13to1, then to1000. With 1 byte per packet the naive parser reportsparsed 0 | broken 873. With 1000, each packet is exactly one event, so it reportsparsed 5 | broken 0and looks correct.That is why the bug survives testing on a fast local network and shows up on a phone. The buffered version prints
events 6with the right text at both sizes. - 2
Add a keep-alive
In the third example, add
": still here",and"",as two extra items at the start of the array that buildsstream.Nothing changes in the output: comments and a blank line without data are ignored. That is the point: a server can send them as often as it likes.
- 3
Use a different line ending
In the third example, change
.join("\r\n")to.join("\r")(old Mac style).The events are still the same for every size. The parser handles all three line endings, including
\rat the very end of a piece, which is the part people forget.
Code & diagrams
This version splits a complete text on blank lines, which is only safe when you already hold the whole text. The next two examples read from a stream.
Preview
Console
Nothing logged yet.Expected console output
": keep-alive\n\nevent: greeting\nid: 7\ndata: hello\n\ndata: line one\ndata: line two\n\ndata:no space after the colon\n\nretry: 3000\n"
(no data: ignored) ": keep-alive"
{ event: 'greeting', id: '7', data: 'hello' }
{ event: 'message', id: undefined, data: 'line one\nline two' }
{ event: 'message', id: undefined, data: 'no space after the colon' }
(no data: ignored) "retry: 3000\n"
CRLF events: [ 'a', 'b' ]On your own machine the packets are usually much bigger, so the naive version appears to work. On a mobile network or behind a proxy it does not.
Preview
Console
Nothing logged yet.Expected console output
packet 1: "data: {\"id\":\""
packet 2: "chatcmpl-fake"
packet 3: "-1\",\"object\":"
packet 4: "\"chat.complet"
packets in total: 72
naive: parsed 0 | broken 71
buffered: events 6 | text: "Chai is tea." | left in buffer: ""The stream ends with a final line and no blank line after it. A browser's EventSource also drops that last event. Real servers always end an event with the blank line.
Preview
Console
Nothing logged yet.Expected console output
{ event: 'greeting', data: 'hello', id: '7' }
{ event: 'message', data: 'line one\nline two', id: '7' }
{ event: 'message', data: 'no space', id: '7' }
events delivered: 3 (the last one, café, never got its blank line, so it is not an event)
pieces of 1 characters: same events: true
pieces of 2 characters: same events: true
pieces of 3 characters: same events: true
pieces of 5 characters: same events: true
pieces of 8 characters: same events: true
pieces of 13 characters: same events: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Split on a single newline
In the second example's buffered part, change buffer.indexOf("\n\n") to buffer.indexOf("\n") and buffer.slice(end + 2) to buffer.slice(end + 1). The first block will be a data line, the next one a blank line.
Myth vs fact
Myth
Each network packet is one SSE message.
Fact
Packets ignore message boundaries. One packet can hold half a line, or several messages. You must buffer until you see the blank line.
Myth
EventSource is the right way to stream a chat answer.
Fact
It can only send GET requests without a body or custom headers. Chat needs a POST with the conversation and usually an Authorization header, so clients use fetch and parse SSE themselves.
Myth
A blank line in the stream is a mistake.
Fact
It is the separator. Without it an event is never delivered.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Servers send
: keep-alivecomments because proxies and load balancers close connections that are idle for a while, often a minute or less. Your parser should ignore them, and your stall watchdog (Topic 3.5) should be written knowing that a comment proves the connection is alive but not that the model is making progress. - ▸
The browser's
EventSourcereconnects on its own and resends the lastidin aLast-Event-IDheader so a server can resume. Afetch-based chat client gets neither. For a chat answer that is usually right, since a retried request would produce a different answer anyway (Topic 3.4). - ▸
Test your parser the way the third example does: run the same body through it cut into every packet size from 1 up, and require identical results. That one test catches nearly every real-world splitting bug, including the
\rand\ncut and the multi-byte character cut (Topic 2.3).
Remember this
- 1
The problem this solves: A model's answer is not one blob. The server wants to send many small messages over one HTTP response: a piece of text, another piece, a finish signal. It needs a simple format both sides agree on. SSE is that format, and almost every AI provider uses it for streaming. This topic teaches the format and the one parsing mistake nearly everyone makes the first time.
- 2
What it is. SSE is a text format sent in a normal HTTP response whose
content-typeistext/event-stream. The server keeps the response open and writes messages into it over time. It is one-way, from server to browser, which is exactly what a streamed answer needs: one request goes up, many messages come back. - 3
One message (an "event") is a few lines. Each line is
field: value. The fields:data:carries the content;event:names the type (the default name ismessage);id:labels the event;retry:suggests a reconnect delay in milliseconds. A blank line ends the event. Without that blank line, an event is not complete. Twodata:lines in one event are joined with a newline between them. - 4
Other rules the first example shows. A line that starts with
:is a comment and is ignored. Servers send comments such as: keep-aliveto stop proxies from closing an idle connection. One optional space after the colon is dropped, sodata: helloanddata:hellomean the same. Unknown fields are ignored. An event that has nodatalines is not delivered. Lines may end with\n,\r\nor\r. If the stream ends before the blank line, the last event is thrown away. - 5
**
EventSourceversusfetch.** Browsers have a built-in reader calledEventSourcethat handles all of the above and even reconnects by itself. But it can only make aGETrequest, with no body and no custom headers. A chat request needs aPOSTcarrying the conversation, and usually anAuthorizationheader. So AI chat clients usefetchand parse the SSE text themselves, which is what this phase builds. (EventSourcestill fits simple feeds with nothing to send, like a notifications stream.) - 6
The mistake: parsing each network packet as one event. A packet is whatever the network delivered (Topic 2.2). It can end in the middle of a line, and a line can end in the middle of a JSON object. The second example proves it: with packets of 13 bytes, a naive parser that treats each packet as an event fails on 71 of 71. The fix is a buffer: add each piece of text to a string, cut off every complete event (up to a blank line), act on those, and keep the unfinished rest for the next piece.
- 7
Even the line ending can be split. With
\r\nline endings, a packet can end between the\rand the\n. A parser that splits on\nalone and sees a trailing\rmay think the line ended one character early. The third example handles this by waiting when a packet ends in\r, and proves the result is the same for every packet size from 1 character up. - 8
Why not WebSockets? A WebSocket is a two-way channel with its own protocol. Streaming an answer needs only one direction after one request, and SSE runs over ordinary HTTP, which passes through proxies, caches and platforms without special setup. That is why AI providers chose SSE.
Explain it without notes
What does a Server-Sent Events message look like, and what ends it?
Why do chat clients use fetch and not EventSource?
Why does treating each network packet as one event fail, and what is the fix?
What are comment lines for, and what should a parser do with them?
What happens to an event if the connection closes before its blank line?
Practice
Write splitEvents(buffer) that takes the current buffer and returns { events, rest }, where events are the complete blocks (without the blank line) and rest is the unfinished text. Use \n\n as the separator.
Extend your function so a block with only comment lines is dropped, and data: lines are joined with a newline.
Trade-offs
- ↔
Hand-writing the parser gives you control and teaches the format. Small mistakes (a split
\r\n, a stripped space) are easy to make, so a tested library parser is worth using once you know the rules; the SDK in Phase 5 contains one. - ↔
SSE over
fetchhas no automatic reconnection or resume. For chat, that is usually what you want, but it means your own Stop, Retry and Regenerate logic (Phase 3) carries all of the recovery.
Done when you can
I can write an SSE message with a name, an id and two data lines.
I can explain what a blank line, a comment and
retry:do.I can say why chat uses
fetchand notEventSource.I can explain why packets are not events and write the buffering loop.
I know to test a parser at every packet size from 1 byte up.