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.
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.
// Stage 2 of 2: open the stream, parse, and reconnect on disconnect.
// Resumes via the Last-Event-ID header so the server can replay missed events.
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;
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);
}
}
}
async function* readChunks(body) {
// Body is an async iterable of strings or Uint8Arrays.
const decoder = new TextDecoder();
for await (const piece of body) {
yield typeof piece === 'string' ? piece : decoder.decode(piece, { stream: true });
}
}
async function consumeSse(url, onEvent, { maxBackoffMs = 30_000, signal } = {}) {
let lastId = '';
let serverRetryHint = 1000;
let attempt = 0;
while (!signal || !signal.aborted) {
try {
const headers = { accept: 'text/event-stream' };
if (lastId) headers['last-event-id'] = lastId;
const res = await fetch(url, { headers, signal });
if (!res.ok) throw new Error(`http_${res.status}`);
attempt = 0; // reset on a successful connect
for await (const evt of parseSse(readChunks(res.body))) {
if (evt.id) lastId = evt.id;
if (evt.retry) serverRetryHint = evt.retry;
onEvent(evt);
}
// Stream closed cleanly; reconnect after the server's hint.
} catch (err) {
if (signal && signal.aborted) return;
// Fall through to reconnect.
}
attempt += 1;
const wait = Math.min(maxBackoffMs, serverRetryHint * Math.min(8, attempt));
await new Promise((r) => setTimeout(r, wait));
}
}
// Demo: a fake server that disconnects once, then resumes from Last-Event-ID.
let calls = 0;
globalThis.fetch = async (url, init) => {
calls += 1;
const lastId = init.headers && init.headers['last-event-id'];
if (calls === 1) {
return {
ok: true,
body: (async function* () {
yield 'id: 1\ndata: hello\n\n';
yield 'id: 2\ndata: world\n\n';
// server hangs up
})(),
};
}
console.log('reconnected with Last-Event-ID:', lastId);
return { ok: true, body: (async function* () { yield 'id: 3\ndata: resumed\n\n'; })() };
};
const ac = new AbortController();
const events = [];
consumeSse('https://stream.example/feed', (evt) => {
events.push(evt.data);
if (events.length === 3) {
ac.abort();
console.log('events seen in order:', events);
}
}, { signal: ac.signal, maxBackoffMs: 50 });The two pieces that turn this from a parser into a real client are the Last-Event-ID header on reconnect and bounded backoff. The header is the entire reason SSE is preferable to bare WebSockets for resumable feeds: a server that buffers events by id can replay everything the client missed without you writing your own checkpoint protocol. I treat the server's retry: hint as a baseline and multiply it by the attempt count, capped at maxBackoffMs, so a flapping server does not pin us in a tight loop. The AbortSignal lets a caller cancel cleanly, which is the difference between a useful library and one your QA team curses every time they kill a worker.
