You want to stream a chat completion. The response is a text/event-stream, so
the browser's built-in EventSource seems like the obvious tool:
const es = new EventSource("https://api.openai.com/v1/chat/completions");
Except that line can't work, and not for a small reason. EventSource can only
issue a GET, and it can't set a single header. The chat-completions
endpoint needs the exact opposite: a POST, an Authorization: Bearer β¦
header, and a JSON body with your messages. The one tool the platform gives you
for SSE is structurally incapable of making an LLM request.
So you drop to fetch β and now you own the parser:
const res = await fetch(url, { method: "POST", headers, body });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// now split on \n\n... or was it \r\n\r\n? what about a \r\n split
// across two chunks? and a multibyte character split mid-sequence?
// and when I break early, did I cancel the reader, or leak the connection?
}
Every one of those comments is a real bug people ship. This is the part of every
streaming integration nobody writes a blog post about. Let's fix it properly.
1. Native EventSource. GET only, no headers β a non-starter for LLM APIs.
And when it does work, it reconnects on any stream end, including a clean
one. For an LLM response that's wrong: a clean end means the model finished.
2. @microsoft/fetch-event-source. The well-known "fetch SSE that can POST."
Genuinely good β but it's a callback API (onmessage, onclose), and you drive
the reconnection policy and the Last-Event-ID bookkeeping yourself.
3. Polyfills β eventsource, launchdarkly-eventsource. These bring the
EventSource interface to Node. That's the catch: you inherit the callback
model, .close() cancellation, and always-on reconnection β the semantics built
for a persistent stream that should reconnect, not a one-shot completion that
ends when the answer is done.
The common thread: either you can't POST, or you get a callback API with
reconnection tuned for the wrong shape of stream.
sse-wire is a zero-dependency,
fetch-based SSE client you consume with for await:
import { sse } from "sse-wire";
const controller = new AbortController();
for await (const event of sse("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({ model, stream: true, messages }),
signal: controller.signal,
})) {
if (event.data === "[DONE]") break; // provider sentinel β not JSON
const delta = JSON.parse(event.data); // { event?, data, id? }
render(delta);
}
That's the whole surface for the common case. Three things make it pull its
weight:
fetch underneath, so method, body, and
headers are first-class. It auto-sets accept: text/event-stream if you
didn't, and passes your signal straight through β so the AbortController
you already have cancels the request for await (const event of sse(...)). break
when you hit [DONE], wrap it in try/finally, compose it like any other
iterable. No listener wiring, no .close().
Abort, and sse-wire throws the standard AbortError, cancels the in-flight
fetch, and cancels the stream reader. Same if you just break out of the loop
early β the reader is cancelled in a finally, so the connection is never left
dangling. It's covered by tests that assert the reader's cancel() actually ran
on abort and on early break.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000); // or AbortSignal.timeout(5_000)
Reconnection is opt-in and fires on transport errors only β a dropped
connection mid-answer, never a clean end:
for await (const event of sse(url, { method: "POST", headers, body, reconnect: true })) {
handle(event);
}
When the connection actually drops, it waits an equal-jitter exponential backoff
(a server retry: directive becomes the base delay), re-issues the request with
Last-Event-ID set to the last event it saw, and keeps yielding into the same
loop. Your consumer never notices the seam.
The wire format is fiddly, and sse-wire handles it so you don't: CR / LF /
CRLF terminators (including a CRLF split across two chunks), multi-line
data: joined with \n, comments, a leading BOM, a NUL-in- id, and a final
line with no newline. It buffers across chunk and UTF-8 boundaries, so a
multibyte character split mid-sequence decodes correctly. There's a property
test that splits the same payload at every single byte offset and asserts the
events come out identical to a one-shot parse.
You can use that parser directly on any byte/text stream, not just a fetch body:
import { parseSSE } from "sse-wire";
for await (const event of parseSSE(someByteStream)) {
// { event?, data, id? }
}
sse-wire is the transport step of a streaming structured-output flow β and
it sits first. Its two siblings pick up from the events it yields:
fetch β SSE (sse-wire) β parse partial JSON (trickle-json) β repair/coerce to schema (coerce-json) β validate
sse-wire`` trickle-json``coerce-json
Here's all three together β stream a completion, assemble the JSON as it arrives,
and coerce the result to a schema:
import { sse } from "sse-wire";
import { StreamingJsonParser } from "trickle-json";
import { coerce } from "coerce-json/zod";
import { z } from "zod";
const Answer = z.object({
sentiment: z.enum(["positive", "neutral", "negative"]),
score: z.number(),
tags: z.array(z.string()).default([]),
});
const parser = new StreamingJsonParser();
parser.on("snapshot", renderPreview); // progressive UI, every chunk
for await (const event of sse(endpoint, { method: "POST", headers, body })) {
if (event.data === "[DONE]") break;
const text = JSON.parse(event.data).choices?.[0]?.delta?.content;
if (text) parser.write(text);
}
const { value, ok, changes } = coerce(parser.end(), Answer);
if (ok) save(value);
else console.warn("could not fully repair:", changes);
sse-wire owns the transport; trickle-json gives you the best value on every
chunk without throwing; coerce-json makes it fit your schema and hands you the
receipts. Each is zero-dependency and works on its own β adopt only the piece you
need.
"But doesn't provider structured output / the official SDK already do this?" The
SDKs help, but they couple the stream to their request lifecycle and their
provider's shapes. sse-wire is a pure, framework-agnostic fetch SSE client:
drop it into any pipeline, point it at any text/event-stream (LLM or not), mock
it in a test, and hand clean events to whatever's next.
npm install sse-wire
If it mishandles some stream it shouldn't β a terminator edge case, a chunk
boundary, an abort that leaks β open an issue with a repro. The parser-parity and
no-leaked-reader guarantees are the whole point, so I want to know. β appreciated
if it saves you a hand-rolled fetch loop.