Server-Sent Events remain the lowest-friction streaming transport for LLM APIs, but the browser’s EventSource can’t send POST bodies or custom auth headers. To consume a POST-based stream cleanly, you need to turn the raw fetch response into an sse async iterator javascript that yields parsed events. The pattern below is dependency-free, runs on Node 18+ and evergreen browsers, and drops straight into a for await...of loop.
Step 1: Recognize what the SSE wire format actually gives you
SSE is a simple line-oriented protocol. Each event is a sequence of fields prefixed by data:, event:, id:, or retry:, terminated by a blank line. A single event may span multiple data: lines; the receiver concatenates them with newlines. Lines starting with a colon are comments and must be ignored.
LLM providers almost always send only data: lines containing JSON, ending with a data: [DONE] sentinel. Knowing this lets you write a minimal parser instead of a full RFC 8625 implementation.
Step 2: Open the stream with fetch and assert invariants
EventSource is GET-only. LLM endpoints require a POST with a bearer token and a stream: true flag, so use fetch. Check the status and content type before touching the body.
async function openStream(messages) {
const res = await fetch("https://api.example.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Authorization": `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages,
stream: true,
}),
});
if (!res.ok) throw new Error(`Upstream HTTP ${res.status}`);
if (!res.body) throw new Error("Response has no body");
return res;
}
If the server returns JSON on error instead of an SSE stream, this catches it early.
Step 3: Decode bytes into lines with a buffer
HTTP chunks do not respect line boundaries. A single reader.read() may return half a line, or three lines plus a fragment. You must accumulate bytes in a string buffer and split on \n. Use TextDecoderStream to handle UTF-8 correctly without manual byte math.
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
handleLine(line);
}
}
The replace(/\r$/, "") strips carriage returns from CRLF terminators. Leftover text after the loop is an incomplete line; keep it in buffer for the next chunk.
Step 4: Extract data frames and detect event boundaries
Maintain a temporary string for the current event’s data. On a blank line, the event is complete: trim, parse, and dispatch. Ignore event: and id: unless you need reconnection logic.
let currentData = "";
function handleLine(line) {
if (line === "") {
if (currentData) {
emit(currentData.trimEnd());
currentData = "";
}
return;
}
if (line.startsWith(":")) return; // comment
if (line.startsWith("data:")) {
currentData += line.slice(5).replace(/^ /, "") + "\n";
}
}
Multi-line data: fields are rare in LLM streams but legal; the concatenation above handles them.
Step 5: Implement the sse async iterator javascript generator
Wrap the read loop in an async function*. This is the core deliverable: a reusable iterator that yields parsed objects and automatically terminates on [DONE] or stream end.
function parseSse(raw) {
if (raw === "[DONE]") return { done: true };
return JSON.parse(raw);
}
async function* sseAsyncIterator(response) {
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
let currentData = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
if (currentData.trim()) yield parseSse(currentData.trimEnd());
break;
}
buffer += value;
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line === "") {
if (currentData) {
yield parseSse(currentData.trimEnd());
currentData = "";
}
} else if (line.startsWith("data:")) {
currentData += line.slice(5).replace(/^ /, "") + "\n";
}
}
}
}
The generator yields plain objects. Your caller decides what to do with { done: true }. Backpressure is inherent: the for await loop pauses reader.read() until the consumer requests the next item.
Step 6: Drive the iterator from application code
Consuming the sse async iterator javascript is now trivial. Extract the delta from each OpenAI-style chunk and write it to stdout or a UI.
const res = await openStream([{ role: "user", content: "Explain SSE." }]);
for await (const evt of sseAsyncIterator(res)) {
if (evt.done) break;
const delta = evt.choices?.[0]?.delta?.content;
if (delta) process.stdout.write(delta);
}
process.stdout.write("\n");
No event listeners, no manual buffer flushing, no nested callbacks. If you need to transform the stream (e.g., accumulate text), wrap it in another async generator.
Step 7: Add abort and error handling
Long-lived streams need cancellation. Pass an AbortSignal to fetch, and ensure the reader is released on error so the underlying TCP socket closes.
async function* safeSseIterator(response, signal) {
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
try {
// ... same loop as before ...
} catch (err) {
await reader.cancel().catch(() => {});
throw err;
} finally {
if (!signal?.aborted) reader.releaseLock?.();
}
}
In Node, an unhandled rejection in the loop will terminate the process; wrap the for await in try/catch. In the browser, aborting the fetch causes reader.read() to reject, which your catch converts into a clean teardown.
Step 8: Use it against a real LLM gateway
Point the same code at any OpenAI-compatible endpoint. For example, an OpenAI-compatible endpoint such as n4n.ai streams deltas as SSE and honors the same stream: true contract, so the iterator above works without modification. Because the gateway handles provider fallback and forwards cache-control hints, your client logic stays identical whether you route to a frontier model or a cheap variant.
You can also send a routing directive header if the gateway supports it; the stream parser does not care about request headers, only the byte format on the wire.
Step 9: Verify the stream end-to-end
First, confirm raw SSE with curl to ensure the server behaves:
curl -N -X POST https://api.example.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}'
You should see data: {...} lines followed by data: [DONE].
Next, run the Node script from Step 6. Success criteria:
- Tokens print incrementally, not all at once.
- The process exits with code 0 after the newline.
- No
UnhandledPromiseRejectionappears if you hit Ctrl-C (proves abort works). currentDatanever leaks across events: addconsole.assert(!currentData)after each yield during dev.
If you see a SyntaxError from JSON.parse, log the raw currentData before parsing—usually it’s a partial chunk because the buffer split inside a JSON string. The line-based parser avoids this because SSE frames are line-delimited, but a malformed proxy might emit bare JSON without data:. In that case, extend handleLine to accept raw lines when no prefix is present.
Edge cases worth handling in production
- Retries:
retry:field sets reconnect delay; irrelevant for one-shotfetchbut useful if you build a persistent client. - IDs:
id:enables resume viaLast-Event-ID; store it if you implement reconnect. - Compression: Some gateways gzip the stream.
fetchdecompresses automatically; do not double-decode. - Browser support:
TextDecoderStreamis available in Chrome 80+, Firefox 113+, Safari 16.4+. For older targets, use a manualTextDecoderand decode chunks yourself.
The sse async iterator javascript pattern scales from a quick script to a hardened client library. Once you have it, every streaming integration—logs, metrics, chat—uses the same for await surface.