Parsing SSE from a Fetch ReadableStream Permalink to this section
Part of Fetch-Based SSE Clients, under Frontend Consumption & Client Patterns.
The event stream format fits on a page, and most hand-written parsers still get it slightly wrong. The failures are intermittent by nature: they depend on where the network happens to split the byte stream, so a parser can pass every test on localhost and corrupt one event in a thousand in production. This guide writes a parser that follows the specification’s algorithm exactly, explains each rule, and gives the test that proves chunk-boundary independence.
Symptom & Developer Intent Permalink to this section
- Occasionally an event arrives with
�in place of an accented character or emoji. - Now and then two events are merged into one, or one event is split in two.
- Events from some servers (which use CRLF line endings) produce an extra blank line and spurious empty events.
- The last event of a stream is dispatched even though the server never finished it.
- Large events make the tab slow in proportion to their size squared.
The intent is a parser whose output depends only on the bytes, never on how they were chunked, and whose cost is linear in the input.
Root Cause Analysis Permalink to this section
The network delivers the response in chunks of arbitrary size. Three structures can be split across a chunk boundary, and each needs state carried between chunks:
The specification’s rules for lines and fields:
- A line ends at CRLF, a lone LF or a lone CR.
- An empty line dispatches the event being built, if its data buffer is non-empty (otherwise it just resets).
- A line starting with
:is a comment and is ignored. - Otherwise the field name is everything before the first
:; the value is everything after it, with one leading space removed if present. A line with no colon is a field name with an empty value. dataappends the value plus a LF to the data buffer;eventsets the type;idsets the last event id unless the value contains NUL;retrysets the reconnection time if the value is all ASCII digits. Unknown fields are ignored.- On dispatch, a single trailing LF is removed from the data buffer, and the event type defaults to
message. - At the end of the stream, an event without its terminating blank line is discarded.
Step-by-Step Resolution Permalink to this section
Step 1 — Decode bytes with a streaming decoder Permalink to this section
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
TextDecoderStream keeps partial characters between chunks. If you decode manually, use one TextDecoder and pass { stream: true } on every call. A leading byte order mark is removed by the decoder by default.
Step 2 — Split lines incrementally, handling CR at the end of a chunk Permalink to this section
export function createLineSplitter(onLine) {
let buf = '';
let pendingCR = false; // last chunk ended with CR: skip a leading LF
return function feed(text) {
let start = 0;
if (pendingCR && text[0] === '\n') start = 1;
pendingCR = false;
buf += start ? text.slice(1) : text;
let i = 0, lineStart = 0;
while (i < buf.length) {
const c = buf.charCodeAt(i);
if (c === 10 || c === 13) { // LF or CR
onLine(buf.slice(lineStart, i));
if (c === 13) {
if (i + 1 < buf.length) { if (buf.charCodeAt(i + 1) === 10) i++; }
else pendingCR = true; // CR at end: LF may follow in next chunk
}
lineStart = i + 1;
}
i++;
}
buf = buf.slice(lineStart); // keep only the unterminated tail
};
}
Scanning once and keeping only the tail makes the cost linear. Re-splitting the whole accumulated buffer on every chunk — buffer.split('\n') on an ever-growing string — is the quadratic version.
Step 3 — Apply the field rules and dispatch Permalink to this section
export function createEventParser(onEvent) {
let data = '', type = '', lastId = '', hasData = false;
return function onLine(line) {
if (line === '') { // dispatch
if (hasData) {
onEvent({ type: type || 'message', data: data.endsWith('\n') ? data.slice(0, -1) : data, id: lastId });
}
data = ''; type = ''; hasData = false;
return;
}
if (line[0] === ':') return; // comment
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? '' : line.slice(colon + 1);
if (value[0] === ' ') value = value.slice(1); // strip exactly one space
switch (field) {
case 'data': data += value + '\n'; hasData = true; break;
case 'event': type = value; break;
case 'id': if (!value.includes('\0')) lastId = value; break;
case 'retry': if (/^\d+$/.test(value)) onEvent({ type: '__retry', retry: Number(value) }); break;
default: break; // unknown fields are ignored
}
};
}
Note that lastId persists across events, exactly as the browser’s last event id does; an event without an id field inherits the previous one.
Step 4 — Wire the stages to the stream Permalink to this section
export async function parseStream(body, onEvent) {
const onLine = createEventParser(onEvent);
const feed = createLineSplitter(onLine);
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) return; // any unterminated event is discarded, per spec
feed(value);
}
}
Step 5 — Test at every chunk boundary Permalink to this section
test('output is independent of chunking', async () => {
const stream =
'retry: 1500\r\n' + 'id: 1\r\nevent: note\r\ndata: héllo 日本\r\ndata: second line\r\n\r\n' +
': comment\n' + 'data:no-space\n\n' + 'id: 2\rdata: cr only\r\r' + 'data: unterminated';
const bytes = new TextEncoder().encode(stream);
const whole = await collect([bytes]);
for (let i = 1; i < bytes.length; i++) {
expect(await collect([bytes.slice(0, i), bytes.slice(i)])).toEqual(whole);
}
expect(whole.filter((e) => e.type !== '__retry').map((e) => e.data))
.toEqual(['héllo 日本\nsecond line', 'no-space', 'cr only']); // unterminated event dropped
});
collect builds a ReadableStream from the given byte chunks and runs parseStream. The fixture deliberately mixes CRLF, LF and lone CR, includes multi-byte characters and a field without a space, and ends mid-event.
Validation & Monitoring Permalink to this section
Run the chunk-boundary test in CI, and add a fuzz test that generates random streams with the encoder from unit testing event stream serialization and random chunkings. For performance, feed a single 5 MB event in 1 KB chunks and assert the parse completes in tens of milliseconds; a quadratic implementation takes seconds.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should I write my own parser or use a library?
Use a library such as eventsource-parser unless you have a reason not to. Write your own only with the full test in this guide, since the bugs it prevents are intermittent and hard to diagnose in production.
Why is an event with only an id and no data not dispatched?
The specification dispatches only when the data buffer is non-empty. An id-only event still updates the last event id, which matters for resumption, but produces no message.
Does the event name default to message?
Yes. An event without an event field is dispatched with the type message, which is what EventSource's onmessage handler receives.
Is splitting on newline characters enough?
No. Line endings may be CR alone, and CRLF may be split across chunks. Treating only LF as a line end merges events from servers that use CR, and splitting naively on both creates spurious blank lines.