EventSource can't POST. Your LLM stream needs it to. A developer has released sse-wire, a zero-dependency, fetch-based Server-Sent Events client for JavaScript that can issue POST requests with custom headers, addressing a structural limitation of the browser's native EventSource API for LLM streaming. The library exposes an async-iterable interface, supports AbortController cancellation, and offers opt-in reconnection that triggers only on transport errors rather than clean stream ends. 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: js 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: js 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 https://www.npmjs.com/package/sse-wire is a zero-dependency, fetch -based SSE client you consume with for await : js 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. js 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: js 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: js 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: js 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.