{"slug": "eventsource-can-t-post-your-llm-stream-needs-it-to", "title": "EventSource can't POST. Your LLM stream needs it to.", "summary": "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.", "body_md": "You want to stream a chat completion. The response is a `text/event-stream`, so\n\nthe browser's built-in `EventSource` seems like the obvious tool:\n\n``` js\nconst es = new EventSource(\"https://api.openai.com/v1/chat/completions\");\n```\n\nExcept that line can't work, and not for a small reason. `EventSource` can only\n\nissue a **GET**, and it **can't set a single header**. The chat-completions\n\nendpoint needs the exact opposite: a `POST`, an `Authorization: Bearer …`\n\nheader, and a JSON body with your messages. The one tool the platform gives you\n\nfor SSE is structurally incapable of making an LLM request.\n\nSo you drop to `fetch` — and now you own the parser:\n\n``` js\nconst res = await fetch(url, { method: \"POST\", headers, body });\nconst reader = res.body!.getReader();\nconst decoder = new TextDecoder();\nlet buf = \"\";\nfor (;;) {\n  const { done, value } = await reader.read();\n  if (done) break;\n  buf += decoder.decode(value, { stream: true });\n  // now split on \\n\\n... or was it \\r\\n\\r\\n? what about a \\r\\n split\n  // across two chunks? and a multibyte character split mid-sequence?\n  // and when I break early, did I cancel the reader, or leak the connection?\n}\n```\n\nEvery one of those comments is a real bug people ship. This is the part of every\n\nstreaming integration nobody writes a blog post about. Let's fix it properly.\n\n**1. Native `EventSource`.** GET only, no headers — a non-starter for LLM APIs.\n\nAnd when it *does* work, it reconnects on **any** stream end, including a clean\n\none. For an LLM response that's wrong: a clean end means the model *finished*.\n\n**2. `@microsoft/fetch-event-source`.** The well-known \"fetch SSE that can POST.\"\n\nGenuinely good — but it's a callback API (`onmessage`, `onclose`), and you drive\n\nthe reconnection policy and the `Last-Event-ID` bookkeeping yourself.\n\n**3. Polyfills — `eventsource`, `launchdarkly-eventsource`.** These bring the\n\n`EventSource` *interface* to Node. That's the catch: you inherit the callback\n\nmodel, `.close()` cancellation, and always-on reconnection — the semantics built\n\nfor a persistent stream that *should* reconnect, not a one-shot completion that\n\nends when the answer is done.\n\nThe common thread: either you can't POST, or you get a callback API with\n\nreconnection tuned for the wrong shape of stream.\n\n[`sse-wire`](https://www.npmjs.com/package/sse-wire) is a zero-dependency,\n\n`fetch`-based SSE client you consume with `for await`:\n\n``` js\nimport { sse } from \"sse-wire\";\n\nconst controller = new AbortController();\n\nfor await (const event of sse(\"https://api.openai.com/v1/chat/completions\", {\n  method: \"POST\",\n  headers: { authorization: `Bearer ${key}`, \"content-type\": \"application/json\" },\n  body: JSON.stringify({ model, stream: true, messages }),\n  signal: controller.signal,\n})) {\n  if (event.data === \"[DONE]\") break;      // provider sentinel — not JSON\n  const delta = JSON.parse(event.data);     // { event?, data, id? }\n  render(delta);\n}\n```\n\nThat's the whole surface for the common case. Three things make it pull its\n\nweight:\n\n`fetch` underneath, so method, body, and\nheaders are first-class. It auto-sets `accept: text/event-stream` if you\ndidn't, and passes your `signal` straight through — so the `AbortController`\nyou already have cancels the request `for await (const event of sse(...))`. `break`\nwhen you hit `[DONE]`, wrap it in `try/finally`, compose it like any other\niterable. No listener wiring, no `.close()`.\nAbort, and `sse-wire` throws the standard `AbortError`, cancels the in-flight\n\n`fetch`, and cancels the stream reader. Same if you just `break` out of the loop\n\nearly — the reader is cancelled in a `finally`, so the connection is never left\n\ndangling. It's covered by tests that assert the reader's `cancel()` actually ran\n\non abort *and* on early break.\n\n``` js\nconst controller = new AbortController();\nsetTimeout(() => controller.abort(), 5_000); // or AbortSignal.timeout(5_000)\n```\n\nReconnection is **opt-in** and fires on **transport errors only** — a dropped\n\nconnection mid-answer, never a clean end:\n\n``` js\nfor await (const event of sse(url, { method: \"POST\", headers, body, reconnect: true })) {\n  handle(event);\n}\n```\n\nWhen the connection actually drops, it waits an equal-jitter exponential backoff\n\n(a server `retry:` directive becomes the base delay), re-issues the request with\n\n`Last-Event-ID` set to the last event it saw, and keeps yielding into the same\n\nloop. Your consumer never notices the seam.\n\nThe wire format is fiddly, and `sse-wire` handles it so you don't: CR / LF /\n\nCRLF terminators (including a **CRLF split across two chunks**), multi-line\n\n`data:` joined with `\\n`, comments, a leading BOM, a NUL-in-` id`, and a final\n\nline with no newline. It buffers across chunk **and** UTF-8 boundaries, so a\n\nmultibyte character split mid-sequence decodes correctly. There's a property\n\ntest that splits the same payload at **every single byte offset** and asserts the\n\nevents come out identical to a one-shot parse.\n\nYou can use that parser directly on any byte/text stream, not just a fetch body:\n\n``` js\nimport { parseSSE } from \"sse-wire\";\n\nfor await (const event of parseSSE(someByteStream)) {\n  // { event?, data, id? }\n}\n```\n\n`sse-wire` is the **transport** step of a streaming structured-output flow — and\n\nit sits first. Its two siblings pick up from the events it yields:\n\n```\nfetch → SSE (sse-wire) → parse partial JSON (trickle-json) → repair/coerce to schema (coerce-json) → validate\n```\n\n`sse-wire`` trickle-json``coerce-json`\nHere's all three together — stream a completion, assemble the JSON as it arrives,\n\nand coerce the result to a schema:\n\n``` js\nimport { sse } from \"sse-wire\";\nimport { StreamingJsonParser } from \"trickle-json\";\nimport { coerce } from \"coerce-json/zod\";\nimport { z } from \"zod\";\n\nconst Answer = z.object({\n  sentiment: z.enum([\"positive\", \"neutral\", \"negative\"]),\n  score: z.number(),\n  tags: z.array(z.string()).default([]),\n});\n\nconst parser = new StreamingJsonParser();\nparser.on(\"snapshot\", renderPreview); // progressive UI, every chunk\n\nfor await (const event of sse(endpoint, { method: \"POST\", headers, body })) {\n  if (event.data === \"[DONE]\") break;\n  const text = JSON.parse(event.data).choices?.[0]?.delta?.content;\n  if (text) parser.write(text);\n}\n\nconst { value, ok, changes } = coerce(parser.end(), Answer);\nif (ok) save(value);\nelse console.warn(\"could not fully repair:\", changes);\n```\n\n`sse-wire` owns the transport; `trickle-json` gives you the best value on every\n\nchunk without throwing; `coerce-json` makes it fit your schema and hands you the\n\nreceipts. Each is zero-dependency and works on its own — adopt only the piece you\n\nneed.\n\n\"But doesn't provider structured output / the official SDK already do this?\" The\n\nSDKs help, but they couple the stream to their request lifecycle and their\n\nprovider's shapes. `sse-wire` is a pure, framework-agnostic `fetch` SSE client:\n\ndrop it into any pipeline, point it at any `text/event-stream` (LLM or not), mock\n\nit in a test, and hand clean events to whatever's next.\n\n```\nnpm install sse-wire\n```\n\nIf it mishandles some stream it shouldn't — a terminator edge case, a chunk\n\nboundary, an abort that leaks — open an issue with a repro. The parser-parity and\n\nno-leaked-reader guarantees are the whole point, so I want to know. ⭐ appreciated\n\nif it saves you a hand-rolled fetch loop.", "url": "https://wpnews.pro/news/eventsource-can-t-post-your-llm-stream-needs-it-to", "canonical_source": "https://dev.to/h1manshu01/eventsource-cant-post-your-llm-stream-needs-it-to-3gll", "published_at": "2026-10-08 15:30:44+00:00", "updated_at": "2026-10-08 15:50:36.330513+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-tools"], "entities": ["sse-wire", "EventSource", "@microsoft/fetch-event-source", "launchdarkly-eventsource", "OpenAI"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/eventsource-can-t-post-your-llm-stream-needs-it-to", "markdown": "https://wpnews.pro/news/eventsource-can-t-post-your-llm-stream-needs-it-to.md", "text": "https://wpnews.pro/news/eventsource-can-t-post-your-llm-stream-needs-it-to.txt", "jsonld": "https://wpnews.pro/news/eventsource-can-t-post-your-llm-stream-needs-it-to.jsonld"}}