A Server-Sent Events Consumer That Reconnects

The Node-friendly SSE client I write whenever the browser `EventSource` is the wrong tool. Parses the line protocol by hand, resumes from `Last-Event-ID`, and applies bounded backoff between retries.

JavaScript
Frontend
2 snippets
sse
real-time
http
code-template
jordandubois

By @jordandubois

March 29, 2026

·

Updated May 18, 2026

813 views

7

4.3 (12)

// SSE wire format: lines separated by \n. Each event is a block separated by
// a blank line. Fields: `data:`, `event:`, `id:`, `retry:`. Anything else is ignored.
// Stage 1 of 2: a generator that yields events from an async chunk source.

async function* parseSse(chunkIter) {
    let buf = '';
    let event = { type: 'message', data: '', id: '', retry: null };
    for await (const chunk of chunkIter) {
        buf += chunk;
        let nl;
        while ((nl = buf.indexOf('\n')) !== -1) {
            const line = buf.slice(0, nl).replace(/\r$/, '');
            buf = buf.slice(nl + 1);
            if (line === '') {
                if (event.data) yield { ...event, data: event.data.replace(/\n$/, '') };
                event = { type: 'message', data: '', id: event.id, retry: event.retry };
                continue;
            }
            if (line.startsWith(':')) continue; // comment
            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);
            if (field === 'data') event.data += value + '\n';
            else if (field === 'event') event.type = value;
            else if (field === 'id') event.id = value;
            else if (field === 'retry') event.retry = parseInt(value, 10);
        }
    }
    if (event.data) yield { ...event, data: event.data.replace(/\n$/, '') };
}

// Drive the parser with a fake stream so this stage runs standalone.
async function* fakeStream() {
    yield 'id: 1\nevent: progress\ndata: 10\n\n';
    yield 'id: 2\ndata: line one\ndata: line two\n\n';
    yield ': keepalive\n\n';
    yield 'retry: 2000\nid: 3\ndata: done\n\n';
}

(async () => {
    for await (const evt of parseSse(fakeStream())) {
        console.log('event:', evt);
    }
})();

The W3C SSE spec is mostly about edge cases: continuation data: lines accumulate with newlines, a leading space after the colon is consumed, lines starting with : are comments (Cloudflare uses these as keepalives), and retry: is a hint to the client about reconnect delay. Writing the parser as an async generator lets the caller iterate events without buffering the whole stream, which matters when you are tailing a 24-hour incident feed. The buffered id survives across event boundaries because the spec says id is sticky until the next id: line. I have re-implemented this enough times to know that keeping the field-parse logic in one place is worth the 30 lines.