cd /news/developer-tools/eventsource-can-t-post-your-llm-stre… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-147673] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

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.

by read6 min views1 publishedOct 8, 2026

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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @sse-wire 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/eventsource-can-t-po…] indexed:0 read:6min 2026-10-08 Β· β€”