Developers consuming raw HTTP streams frequently encounter concatenated payloads, missing event boundaries, or unhandled field prefixes when processing text/event-stream responses. The Network tab displays raw, unformatted byte sequences, and custom parsers routinely fail to split on double-newline boundaries. Multi-line data: fields are truncated or incorrectly concatenated, causing downstream deserialization failures. The core intent is to transform a continuous byte stream into discrete, structured events containing data, event, id, and retry fields without dropping messages or introducing memory leaks. This requires moving beyond standard fetch() JSON parsing and implementing a true streaming parser aligned with the SSE Protocol Fundamentals & Architecture.
Parsing failures stem from treating the stream as a monolithic string or standard HTTP response body. The text/event-stream MIME type enforces a strict line-based framing protocol. Common implementation errors include:
response.text() or response.json(), which buffers until connection closure, defeating real-time delivery.\r\n vs \n normalization, causing line-splitting logic to miss boundaries.\n\n) event delimiter or ignoring comment lines (: ).Without a chunk-aware reader, the parser cannot reconstruct Understanding the Event Stream Format specifications accurately, leading to silent message loss or unbounded memory growth.
Implement a deterministic, chunk-aware stream parser using the following sequence:
response.body.getReader() to access the ReadableStreamDefaultReader. Do not await response.text().Accept: text/event-stream and validate the response returns Content-Type: text/event-stream; charset=utf-8.Uint8Array chunk to it.\r\n with \n immediately after decoding to standardize split behavior.\n. Keep the final array element in the buffer for the next iteration to handle mid-chunk field boundaries.data: , event: , id: , and retry: prefixes. Map values to a temporary event object.data: fields. Append consecutive data: lines to an array. Join them with \n when emitting.\n\n) signals event completion. Dispatch the assembled object and reset parser state.id for automatic reconnection tracking. Parse retry as an integer (milliseconds) and apply to your backoff strategy.async function parseSSEStream(response) {
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
throw new TypeError('Expected text/event-stream MIME type');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode chunk and append to persistent buffer
buffer += decoder.decode(value, { stream: true });
// Split on newlines, preserve incomplete trailing fragment
const lines = buffer.split('\n');
buffer = lines.pop();
let eventObj = { data: [], event: 'message', id: null, retry: null };
for (const rawLine of lines) {
// Normalize \r\n -> \n
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
// Ignore comments
if (line.startsWith(':')) continue;
// Empty line triggers event emission
if (line === '') {
if (eventObj.data.length > 0) {
yield {
data: eventObj.data.join('\n'),
event: eventObj.event,
id: eventObj.id,
retry: eventObj.retry
};
}
// Reset for next event
eventObj = { data: [], event: 'message', id: null, retry: null };
continue;
}
// Parse field: value
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue; // Malformed line, skip
const field = line.slice(0, colonIndex);
// Spec: single space after colon is stripped; otherwise keep exact value
const value = line[colonIndex + 1] === ' ' ? line.slice(colonIndex + 2) : line.slice(colonIndex + 1);
switch (field) {
case 'data': eventObj.data.push(value); break;
case 'event': eventObj.event = value; break;
case 'id': eventObj.id = value; break;
case 'retry': eventObj.retry = parseInt(value, 10) || null; break;
}
}
}
}
// Usage
// const res = await fetch('/api/stream', { headers: { Accept: 'text/event-stream' } });
// for await (const evt of parseSSEStream(res)) { console.log(evt); }
Verify parser correctness by injecting controlled payloads with known edge cases. Monitor runtime behavior to prevent buffer bloat and ensure zero message loss under network jitter.
Fetch/XHR or WS (if multiplexed).Content-Type: text/event-stream.curl -N -H "Accept: text/event-stream" <url> to inspect raw byte boundaries.\r\n, \n, \rdata: concatenation matches spec exactly (joined by \n, not retry and id> 100KB:commentAbortController