On this page
- Start with bytes, not strings
- Decode UTF-8 incrementally
- Lines can end in more than one way
- A blank line dispatches the event
- data: may appear multiple times
- The optional space after : is special
- Lines without a colon are legal
- Comment lines begin with :
- event: names the event type
- id: is stream metadata, not your operation ID
- retry: is reconnect guidance
- EOF is not automatically an event terminator
- JSON parsing belongs after SSE framing
- [DONE] is not part of SSE itself
- A robust parser state machine
- Decoder state
- Line-buffer state
- Event state
- Preserve parser state across arbitrary reads
- Do not trim event data casually
- Do not concatenate text deltas without event semantics
- Error events can arrive inside an HTTP 200 stream
- A closed stream is not automatically success
- Keep parsing off the UI hot path when needed
- Bound buffers defensively
- A compact TypeScript parser sketch
- Test fixtures that catch real bugs
- Normal single-line event
- Multiple data lines
- Comments
- Custom event type
- Empty data
- Colon inside value
- CRLF
- Unicode split across reads
- Incomplete final event
- Huge malformed line
- Fuzz chunk boundaries
- Separate transport errors from parse errors
- Where BYOKchat fits
- Further reading
A Server-Sent Events parser looks easy until it meets a real network.
The naive implementation is usually:
read chunk
split chunk by "\n\n"
parse each piece
That works in demos and fails in production because network chunks are not protocol messages.
A chunk can end:
- halfway through an SSE field;
- halfway through
\r\n; - halfway through a multibyte UTF-8 code point;
- halfway through JSON inside a
data:field; - after the first of several
data:lines in one event.
The core rule is:
Parse the SSE grammar as a stream. Never assume a socket/read chunk corresponds to one line or one event.
Start with bytes, not strings
HTTP response bodies arrive as bytes.
Suppose the server sends:
data: {"delta":"hello"}
The client might receive:
chunk 1: data: {"del
chunk 2: ta":"hel
chunk 3: lo"}\n
chunk 4: \n
Or any other split.
The parser must preserve incomplete state between reads.
Decode UTF-8 incrementally
SSE event streams are UTF-8.
A multibyte character can be split across network reads:
chunk A: ... first bytes of 😀
chunk B: ... remaining bytes
If each chunk is independently decoded with replacement-on-error behavior, the user can see corrupted text.
Use an incremental UTF-8 decoder that retains incomplete code units between feeds.
In web JavaScript, TextDecoder can be used incrementally:
const decoder = new TextDecoder("utf-8");
function decodeChunk(bytes, isFinal = false) {
return decoder.decode(bytes, { stream: !isFinal });
}
The exact API differs by platform, but the principle is the same.
Lines can end in more than one way
The SSE specification accepts:
LF \n
CRLF \r\n
CR \r
Do not build a parser that only recognizes \n if it is intended to be standards-compliant.
In practice, many APIs emit LF or CRLF. Supporting all legal forms is cheap once line parsing is separated from event parsing.
A blank line dispatches the event
An SSE event is a group of fields followed by a blank line.
Example:
event: delta
data: {"text":"hello"}
id: 42
The empty line means:
finish current event
→ assemble fields
→ dispatch
→ reset per-event buffers
A line ending by itself is therefore meaningful state, not disposable whitespace.
data: may appear multiple times
This stream:
data: first
data: second
data: third
produces one event whose data is conceptually:
first\nsecond\nthird
A parser that stores only the last data: field is wrong.
A practical event accumulator can keep:
type PendingEvent = {
eventType?: string;
dataLines: string[];
id?: string;
retry?: number;
};
Then dispatch with:
const data = pending.dataLines.join("\n");
The optional space after : is special
These two lines are equivalent:
data:test
data: test
If a field contains a colon, the first colon separates the field name from the value. If the value begins with one space, that one space is ignored.
Do not use a generic split(":"), because the value itself may contain more colons:
data: https://example.com:8443/path
Instead:
find first colon
fieldName = before colon
fieldValue = after colon
if fieldValue starts with one space: remove that one space
Lines without a colon are legal
A field can appear with no colon:
id
That means field name id with an empty value.
This matters because an empty id can reset the remembered event ID in compliant EventSource behavior.
Do not reject colon-less lines just because provider streams rarely use them.
Comment lines begin with :
Example:
: keepalive
Comment lines do not contribute data to an event.
Servers often use them as keepalives to prevent intermediaries from treating an otherwise idle stream as dead.
A parser should ignore the content after the leading colon while still treating the line as valid stream input.
event: names the event type
Without an event: field, browser SSE semantics use the default event type message.
AI providers often use typed events such as:
event: response.output_text.delta
event: content_block_delta
event: error
A provider adapter can map those provider-native names into a neutral model after parsing.
Do not mix these two layers:
SSE parser: bytes → SSE event
provider parser: SSE event → provider event object
adapter: provider event → app semantic event
Keeping them separate makes bugs much easier to isolate.
id: is stream metadata, not your operation ID
SSE has an id field used for event replay/reconnect semantics.
That field is unrelated to application identifiers such as:
response_id
message_id
tool_call_id
operation_id
Do not overload the SSE event ID as the model response ID unless the protocol explicitly defines that relationship.
retry: is reconnect guidance
A valid numeric retry: field can tell an EventSource client how long to wait before reconnecting.
A manually parsed AI POST stream may choose not to use automatic SSE reconnect semantics at all.
That is often correct because replaying an AI POST can create a new generation.
So the low-level parser can expose retry, while the higher-level generation state machine decides whether reconnect is semantically safe.
EOF is not automatically an event terminator
A subtle standards detail: an event is dispatched when a blank line terminates it.
Do not assume that reaching EOF means an unterminated final block must be delivered as a valid event.
For provider-specific protocols, the server may define its own completion markers, but your SSE framing layer should not silently invent a missing delimiter.
This matters when the stream is truncated:
data: {"partial": true}
<connection dies>
That should usually remain incomplete rather than being promoted into a successful final event.
JSON parsing belongs after SSE framing
Many AI providers put JSON inside data::
data: {"type":"delta","text":"hello"}
Do not attempt JSON.parse() on every network chunk.
Correct order:
Each stage has one responsibility.
[DONE] is not part of SSE itself
Some APIs historically use sentinel data such as:
data: [DONE]
That is a provider/application convention, not a generic SSE rule.
Your SSE parser should emit the data string [DONE]. The provider adapter can decide that it means completion.
This separation prevents one provider’s convention from leaking into every stream parser.
A robust parser state machine
A simple design has three levels of state:
Decoder state
Holds incomplete UTF-8 bytes between reads.
Line-buffer state
Holds decoded characters until one complete line ending is found.
Event state
Holds fields for the current event until a blank line arrives.
Conceptually:
feed(bytes)
↓
decode incrementally
↓
append text to lineBuffer
↓
while complete line exists:
processLine(line)
Then:
processLine("")
→ dispatch pending event if it has data
→ reset pending event fields
processLine(":...")
→ ignore comment
processLine("field:value")
→ apply SSE field semantics
Preserve parser state across arbitrary reads
A useful invariant is:
Feeding the same byte stream with any chunk boundaries must produce the same sequence of parsed events.
That is one of the best tests you can write.
Take one canonical stream and feed it as:
all bytes at once
1 byte at a time
2 bytes at a time
random chunk sizes
splits exactly inside CRLF
splits inside UTF-8 characters
splits inside JSON string escapes
Every run should yield identical parsed SSE events.
Do not trim event data casually
This is dangerous:
data.trim()
Whitespace can be meaningful inside:
- model text;
- code blocks;
- JSON strings;
- Markdown;
- structured arguments.
Only remove syntax mandated by SSE framing, such as the optional single space immediately after the field colon.
Do not normalize payload whitespace globally.
Do not concatenate text deltas without event semantics
Suppose the provider emits:
text delta
reasoning delta
tool-call argument delta
usage event
If the client simply appends every data string to one output buffer, it will corrupt the conversation.
The SSE parser should know nothing about text rendering. The provider adapter should inspect event type/content and route semantic events correctly.
Error events can arrive inside an HTTP 200 stream
The initial HTTP request can succeed, then the provider can emit an application-level error later.
So a complete stream pipeline needs two error layers:
HTTP/setup error
stream event error
For example:
HTTP 200
→ 30 deltas
→ provider error event
→ connection closes
Do not mark the generation successful merely because response status was 200.
A closed stream is not automatically success
Connection EOF can mean:
- normal provider completion;
- proxy truncation;
- server crash;
- client cancellation;
- idle timeout;
- network loss.
Your provider adapter should know what a valid terminal sequence looks like.
Depending on the API, that may be:
explicit completed event
finish reason
final response object
[DONE] sentinel
Only the higher-level protocol layer can distinguish normal end from suspicious EOF.
Keep parsing off the UI hot path when needed
Streaming text can produce many tiny events.
A parser should be lightweight, but downstream work can be expensive:
- JSON decoding;
- schema validation;
- Markdown rendering;
- syntax highlighting;
- database writes;
- layout updates.
Avoid making every byte read synchronously trigger a full UI render.
A common pipeline is:
network parser emits semantic delta
→ append to in-memory generation buffer
→ coalesce UI updates every small interval/frame
→ persist at larger checkpoints
Bound buffers defensively
A malformed server could send an extremely long line without any line break.
If your parser waits forever, memory can grow unbounded.
Consider reasonable defensive limits for:
- maximum single SSE line size;
- maximum pending event size;
- maximum JSON event payload size.
Choose limits based on actual provider behavior, especially because tool arguments can be much larger than text deltas.
If a limit is exceeded, fail explicitly rather than truncating silently.
A compact TypeScript parser sketch
This is intentionally simplified, but it shows the right state boundaries:
type SSEEvent = {
event?: string;
data: string;
id?: string;
};
class SSEParser {
private buffer = "";
private dataLines: string[] = [];
private eventType: string | undefined;
private eventId: string | undefined;
feed(text: string, emit: (event: SSEEvent) => void) {
this.buffer += text;
while (true) {
const line = this.takeLine();
if (line === null) break;
this.processLine(line, emit);
}
}
private processLine(line: string, emit: (event: SSEEvent) => void) {
if (line === "") {
if (this.dataLines.length > 0) {
emit({
event: this.eventType,
data: this.dataLines.join("\n"),
id: this.eventId,
});
}
this.dataLines = [];
this.eventType = undefined;
return;
}
if (line.startsWith(":")) return;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
switch (field) {
case "data":
this.dataLines.push(value);
break;
case "event":
this.eventType = value;
break;
case "id":
this.eventId = value;
break;
}
}
private takeLine(): string | null {
// Production code must handle LF, CRLF, CR and a CR split across feeds.
return null;
}
}
The deliberately omitted takeLine() is where many edge cases live. Test it heavily.
Test fixtures that catch real bugs
Include fixtures for:
Normal single-line event
data: hello
Multiple data lines
data: hello
data: world
Expected data:
hello\nworld
Comments
: keepalive
data: hello
Custom event type
event: tool_delta
data: {"x":1}
Empty data
data:
Colon inside value
data: https://example.com:8443
CRLF
data: hello\r\n\r\n
Unicode split across reads
Feed one UTF-8 code point across two byte chunks.
Incomplete final event
data: partial
without the terminating blank line.
Huge malformed line
Verify the parser rejects it according to your configured limits.
Fuzz chunk boundaries
One of the highest-value parser tests is chunk-boundary fuzzing.
Given an encoded stream bytes, repeatedly choose random split positions:
[0..8] [8..13] [13..14] [14..37] ...
Feed those slices and compare the output to a known-good expected event list.
This catches bugs that ordinary fixture tests miss.
Separate transport errors from parse errors
Useful error categories include:
network disconnected
HTTP error before stream
invalid UTF-8 policy violation
SSE framing limit exceeded
invalid provider JSON
unknown provider event
provider-declared error
unexpected EOF before terminal event
Do not collapse all of these into “stream failed.”
Good classification makes recovery and telemetry much more useful.
Where BYOKchat fits
A multi-provider client should have one tested SSE framing utility where appropriate, then separate provider decoders above it. That lets Anthropic-style events, OpenAI-style events, compatible endpoints, and future providers share reliable byte/line handling without sharing provider semantics accidentally.
The UI should receive semantic events only after both layers have succeeded.