BYOKchat Blog

How to Parse Server-Sent Events Correctly

Learn how to parse SSE streams safely across arbitrary byte chunks, UTF-8 boundaries, multiline data fields, event IDs, comments, and disconnects.

· 7 min read

On this page
  1. Start with bytes, not strings
  2. Decode UTF-8 incrementally
  3. Lines can end in more than one way
  4. A blank line dispatches the event
  5. data: may appear multiple times
  6. The optional space after : is special
  7. Lines without a colon are legal
  8. Comment lines begin with :
  9. event: names the event type
  10. id: is stream metadata, not your operation ID
  11. retry: is reconnect guidance
  12. EOF is not automatically an event terminator
  13. JSON parsing belongs after SSE framing
  14. [DONE] is not part of SSE itself
  15. A robust parser state machine
  16. Decoder state
  17. Line-buffer state
  18. Event state
  19. Preserve parser state across arbitrary reads
  20. Do not trim event data casually
  21. Do not concatenate text deltas without event semantics
  22. Error events can arrive inside an HTTP 200 stream
  23. A closed stream is not automatically success
  24. Keep parsing off the UI hot path when needed
  25. Bound buffers defensively
  26. A compact TypeScript parser sketch
  27. Test fixtures that catch real bugs
  28. Normal single-line event
  29. Multiple data lines
  30. Comments
  31. Custom event type
  32. Empty data
  33. Colon inside value
  34. CRLF
  35. Unicode split across reads
  36. Incomplete final event
  37. Huge malformed line
  38. Fuzz chunk boundaries
  39. Separate transport errors from parse errors
  40. Where BYOKchat fits
  41. 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

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:

Diagram illustrating the surrounding section

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.

Further reading

Keep reading