TT Lab
Get started
Learn Learning paths Courses

SSE — How the Server Speaks First

Disconnects Are Normal; Gaps Are the Incident

Continue in TT Lab

In one line

SSE reconnection comes free from the browser, but filling the gap is the server's job. The link between the two is just the id field and the Last-Event-ID header.

Why this was needed

If you leave a notification screen open on the subway, the connection drops every few minutes. The browser reconnects on its own — up to this point, EventSource does it for you. The problem is what the server sent while the connection was down.

If it just reconnects, that stretch is lost forever. Three notifications never happened, and an order status jumps suddenly from "payment complete" to "shipping." Conversely, if the server always resends from the beginning, the same notification pops up twice. Both are the kind of breakage users notice right away.

The specification solves this problem with one very small mechanism. If the server attaches an id to each event, the browser remembers the last id it saw and, when it reconnects, sends it back in the Last-Event-ID request header. The server only needs to send from after that.

How it works

The specification is stricter than you might expect

The parsing rules of the HTML Standard, section 9.2 work as follows, reading one line at a time. There are three things to remember — the data buffer, the event type buffer, and the last event ID buffer.

빈 줄            → 이벤트를 내보낸다
콜론으로 시작    → 그 줄은 무시 (주석 · 하트비트)
콜론이 있다      → 앞이 필드 이름, 뒤가 값. 값이 공백으로 시작하면 하나만 뗀다
콜론이 없다      → 줄 전체가 필드 이름, 값은 빈 문자열

The handling of each field is also written out in the specification.

Field Rule
data Append the value to the buffer, then append one newline
event Set the event type buffer to that value
id Set the last id buffer only if the value contains no U+0000 NULL
retry Change the reconnection time only if the value consists solely of ASCII digits
Anything else Ignore it

There are two things people get wrong most often here. First, the last id buffer is not reset even after an event is dispatched. Only the data buffer and the event type buffer are emptied. So even if events without ids keep coming, the number used for reconnection stays as it was. Second, if the data buffer is an empty string, no event is dispatched. A block containing only retry: or a block containing only comments is a setting, not an event.

A truncated event is discarded

The specification is firm — if the file ends before the final blank line, that incomplete event is not dispatched. This is a rule so that half a JSON document doesn't reach the screen when the connection drops in the middle of an event, and at the same time it makes Last-Event-ID point only as far as what was actually fully received. This is why resuming becomes exact.

The browser reconnects; the server fills the gap

The reconnection time is also in the specification. The browser raises an error, sets readyState to CONNECTING, waits for the reconnection time, and then connects again. The initial value of that time is left to the implementation (the specification only says "a few seconds"), and the server can change it with retry:. If the previous attempt failed, the specification allows the browser to add exponential backoff on top.

And when it connects again, only if the last id string is not an empty string, it includes the Last-Event-ID header. The server can handle the two cases separately: with no header, "from the beginning," and with one, "from after that."

The server-side counterpart is a replay window. You keep the most recent N events in a ring buffer and return what comes after the requested id. If the id is outside the window, instead of sending what remains, you must signal "cannot resume" and make the client fetch everything again. If you send only what you have for an unknown id, the middle has a hole and no one knows it.

Resuming works only as far as the server remembers

Even if the client sends the header correctly, resuming can't work if the server remembers nothing. The size of the replay window ultimately sets how long a disconnection can last. With a window of 100 and a stream sending 50 per second, it is only 2 seconds of insurance. You should first work out whether to size the window by time or by count, and whether a full resend when you fall outside it is affordable, and only then pick the number.

What it looks like in the field

There is a common accident with LLM token streaming. Tokens have no ids, so reconnection always starts from the beginning, and the user sees the same sentence printed twice. Conversely, there is the case where ids are attached but the server remembers nothing, so it always returns an empty stream to a resume request — this one is filed as "sometimes the answer stops midway."

One team parsed ids as integers. In the specification, an id is a string that just must not contain NULL, LF, or CR, so on the day sharding made them start using ids like b7-1042, resuming died entirely. The exception was left only in the server log, and the screen quietly received everything again from the beginning.

What you will do in the next lab

You implement the specification's parsing rules as written. Input split into chunks, the three kinds of line endings, comments, lines without a colon, an id containing NULL, a non-numeric retry, and a truncated last event. On top of that, you add the server-side replay window and the reconnection header.