Reliable SSE Streaming: Why We Moved From fetch to XMLHttpRequest
Most teams that build streaming UIs are migrating toward the Fetch API, not away from it. `fetch()` with a `ReadableStream` body is the modern, header-friendly way to consume Server-Sent Events, and the platform's direction has favored investing in streaming `fetch()` over the older `EventSource` object. So it surprises people when we tell them that in April 2026 we did the opposite: we ripped streaming `fetch` out of Macha's agent chat and replaced it with a plain `XMLHttpRequest`.
We didn't do it for nostalgia. We did it because a feature that operators rely on — watching an AI agent's tool calls happen in real time — was quietly broken for some of them. The events were being sent correctly by the server; they just weren't arriving on screen when they should. This post is the honest write-up of that bug: what SSE is doing under the hood, why fetch + ReadableStream buffered our events, why XHR fixed it, and the trade-offs we accepted. If you're streaming LLM or tool output to a browser, the same trap is waiting for you.
The symptom: a status log that wouldn't move
Macha is an AI agent layer that sits on top of your existing helpdesk — Zendesk, Freshdesk, Gorgias, Front — and runs tools on your behalf: looking up an order in Shopify, pulling a charge from Stripe, transcribing a voicemail attachment, posting a reply to a ticket. When an agent works a conversation, the UI streams a live status log so a human can watch each step: "Using Get Order," "Using Transcribe Audio," "Drafting reply."
That status log is not cosmetic. It's the difference between an operator trusting the agent and an operator staring at a frozen spinner wondering if anything is happening. For a Macha agent running on Zendesk, the status line is the audit trail in motion.
The bug we shipped a fix for on April 23, 2026 was exactly this: tool-call progress wasn't showing during execution. The message would sit on "thinking," and then — pop — the finished answer would appear all at once, with the intermediate steps that were supposed to stream having never rendered. The server was emitting every SSE event on time. The browser just wasn't surfacing them until the response was effectively done.
A 60-second refresher on SSE
Server-Sent Events is a dead-simple streaming protocol: the server holds an HTTP response open and writes UTF-8 text in a specific shape — data: lines, optional event: and id: lines, separated by blank lines — flushing each chunk as it goes. The client reads the response incrementally and fires a handler per event. No WebSocket upgrade, no bidirectional complexity; it's just a long-lived HTTP response you read as it arrives.
The browser ships a native client for this, EventSource, and it's lovely — automatic reconnection, Last-Event-ID replay, the works. There's just one problem for an app like ours:
The native EventSource API can only issue GET requests, cannot set custom headers, and can only pass input through the URL.
Our agent chat endpoint is a POST. It carries an auth token in an Authorization header and a JSON body describing the conversation. None of that fits through EventSource. So like nearly everyone building authenticated streaming today, we'd reached for the standard alternative: consume the SSE stream ourselves with fetch.
Why we were on fetch + ReadableStream in the first place
The Fetch API exposes the response body as a ReadableStream. You take a reader, pull chunks, decode the bytes, and split on the SSE delimiter. Roughly:
const res = await fetch("/api/agent/chat", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// split buffer on "\n\n", parse each complete SSE event, dispatch it…
}
This is the textbook pattern, and on paper it's strictly better than EventSource: it does POST, it carries headers, and consuming a streamed response body this way is a shipped, broadly-supported platform feature — the approach web.dev's streams guide walks you through for reading data as it arrives. For most of our users it worked perfectly. The trouble is that "the bytes arrived" and "the bytes arrived promptly, one event at a time" are two different guarantees — and ReadableStream only ever promised you the first.
The actual failure: buffering you don't control
reader.read() does not resolve once per flush() on the server. It resolves when a layer somewhere between the server's socket and your JavaScript decides to hand you bytes. And there are several such layers, each with its own buffering rules:
- The compression layer. If the response is
gziporbrotliencoded, the compressor holds bytes until it has enough to compress a block efficiently. Your server can flush a tidy littledata: Using Transcribe Audio\n\nand the compressor will happily sit on it. SendingAccept-Encoding: identityto disable compression is a common diagnostic precisely because compression is such a frequent culprit. - WebKit's read behaviour. Safari/WebKit tends to hold the opening bytes of a streamed response before it surfaces anything to the page. High Performance Browser Networking puts the general rule plainly — "some browsers may release data immediately, while others may buffer small responses and release them in larger chunks" — and WebKit's own write-up on fetching bytes is the canonical reference for how it reads streamed bodies. (The exact byte threshold gets quoted around a kilobyte in the wild, but it isn't a documented constant, so don't hard-code your design around a magic number.) Short, fast SSE events — exactly the "Using X" status pings — can sit under that buffer and never paint until later traffic pushes it over the line.
- Intermediate proxies. Load balancers, CDNs, and reverse proxies between you and the browser can re-buffer a chunked response, coalescing many small writes into fewer, larger reads.
- No per-chunk guarantee, full stop. This is the root of it: the Streams API explicitly does not promise that one server write equals one
read(). The Bun maintainers summarize it in discussion #13923 — titled, plainly, "using ReadableStream as Response will not send each chunk one by one but in batches" — and the takeaway generalizes: implementations are free to coalesce writes, and there's no guarantee every chunk is flushed individually.
Put those together and you get our bug. The model's final answer was a big chunk of text, so it always blew past every buffer threshold and rendered. The little tool-status events in front of it were small, frequent, and — on the wrong combination of browser, compression, and network path — got swallowed into a later batch. They effectively arrived bundled with the final answer, which is the same as not streaming at all.
The maddening part is that this is invisible in development. On localhost, with no compression and no proxy, every event flushes instantly and the status log looks flawless. The buffering only bites in production, intermittently, depending on the user's browser and network. It's the kind of bug that earns a "works on my machine" and then three support tickets a week.
Why XMLHttpRequest fixed it
XMLHttpRequest is the older API everyone loves to leave behind, but it has one property that, for incremental text, is rock solid: as the response streams in, responseText grows, and the onprogress (and onreadystatechange at readyState === 3) handler fires repeatedly with the data received so far. You don't manage a byte reader; you diff the new tail of responseText against what you've already parsed.
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/agent/chat");
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
xhr.setRequestHeader("Content-Type", "application/json");
let seen = 0;
xhr.onprogress = () => {
const fresh = xhr.responseText.slice(seen);
seen = xhr.responseText.length;
// parse complete SSE events out of `fresh` + leftover, dispatch each…
};
xhr.send(JSON.stringify(payload));
It keeps every advantage we needed fetch for — it does POST, it sets the Authorization header — while delivering the incremental text behaviour reliably across the browsers our customers actually use. In practice XHR's progressive responseText has been treated as the dependable substrate for HTTP streaming for over a decade; modern browsers share a consistent implementation of onprogress against a growing responseText. It is, ironically, the boring choice that just works.
The fix in the changelog reads like one line — "Switched from fetch ReadableStream to XMLHttpRequest for reliable SSE event delivery" — but that's the whole point. The reliability win came from choosing the API with the simpler, better-specified incremental-read behaviour, not the newer one.
The same April 23 release leaned into this: you can now simulate an agent against a real ticket and watch it work in a test conversation with live SSE streaming — tool calls appearing in the status log step by step. That feature is only as trustworthy as the transport under it.
EventSource vs. fetch+ReadableStream vs. XMLHttpRequest
There's no universally "best" choice here — there's the right tool for your constraints. Here's the honest comparison for streaming SSE-style output to a browser:
| EventSource | fetch + ReadableStream | XMLHttpRequest | |
|---|---|---|---|
| POST / request body | ❌ GET only | ✅ | ✅ |
| Custom headers (auth) | ❌ | ✅ | ✅ |
Auto-reconnect + Last-Event-ID | ✅ built-in | ❌ DIY | ❌ DIY |
| Incremental delivery model | Per-event (native) | Per-read() (may batch) | Growing responseText (per-progress) |
| Real-time small events | ✅ reliable | ⚠️ buffer-sensitive | ✅ reliable |
| Modern / future-facing | Stagnant | ✅ actively invested | Legacy but stable |
If your endpoint can be a plain GET with no auth header, native EventSource is still the least-code, most-robust option — use it. If you need POST and headers (most authenticated apps), you're choosing between fetch and XHR, and the deciding question is how sensitive is your UX to small, frequent events arriving on time. For a chat answer that streams as one growing blob, fetch is fine. For a status log of discrete tool calls, where each event is tiny and the whole value is the timing, XHR's progressive responseText removed an entire class of buffering bugs for us.
Watch-outs: this is not "XHR good, fetch bad"
We'd be misrepresenting the trade if we sold this as a clean win. Reasons you might not follow us:
- XHR has its own warm-up quirk. Browsers don't reliably fire
onprogresson the very first byte — they wait until a little data has buffered, so the first events on a cold stream can still lag. The XMLHttpRequest onprogress chronicles catalogue these engine-by-engine quirks, and HPBN documents the same buffering variability. A few bytes of padding/comment at the start of the stream (: keep-alive\n\n) is a standard nudge. - You lose
fetch's ergonomics. NoAbortController(XHR usesxhr.abort()), no promise-native flow, no request streaming. If you've standardised onfetcheverywhere, a lone XHR is a wart in the codebase. fetchcan be made to behave —Content-Encoding: none(oridentity), explicitflush()after every write,X-Accel-Buffering: nofor nginx, and avoiding small events under WebKit's threshold will fix most buffering. We chose to switch transports instead of chasing every layer, but tuning the stack is a legitimate alternative.- Consider
@microsoft/fetch-event-source. If you wantfetch's power andEventSource-style event parsing plus reconnection, this library is the popular middle path. It doesn't escape the underlying buffering physics, but it removes the hand-rolled parsing.
The meta-lesson is the durable one: newer is not the same as more reliable. When a primitive's job is "deliver tiny things promptly," pick the API whose incremental-delivery contract is the simplest to reason about — and test it on a real network, behind real compression, in Safari, not just on localhost.
Why this matters for an AI agent platform
Streaming reliability sounds like a back-office concern until you remember what these agents do. A Macha agent isn't just printing tokens — it's running tools that take real actions: issuing a Stripe refund, updating a Zendesk ticket's status, transcribing a customer's voicemail. Every one of those is a credit-metered AI action (priced per action by model, with GPT-5.4 Mini at the low end), and every one of them is something a human supervisor may want to see happen before it completes. A status log that lags makes the agent feel like a black box; a status log that streams makes it feel like a colleague narrating their work. That trust is the product.
FAQ
Why not just use EventSource? Because Macha's chat endpoint is an authenticated POST. Native EventSource only supports GET requests, can't send custom headers, and passes input only through the URL — so it can't carry our Authorization header or JSON body.
Is fetch + ReadableStream broken? No. It's a great, modern API and it's the right choice for plenty of streaming. Its incremental-delivery behaviour is just sensitive to buffering — compression, WebKit's tendency to hold small streamed responses, and intermediate proxies can batch small events — which broke our specific UX of many tiny tool-status events.
Does XHR work in all the browsers you support? Yes. Modern browsers share a consistent implementation where onprogress fires against a growing responseText. The main caveat is that the first onprogress can lag until a little data has buffered, which a small keep-alive prelude smooths over.
Will you go back to fetch later? Possibly, if we revisit the compression/proxy tuning end-to-end or adopt a fetch-based SSE library. The current call optimises for reliable real-time delivery with the least moving parts.
How do I see this streaming in Macha? Open a test conversation on an agent and watch the status log render each tool call as it runs. More walkthroughs live in the docs and on the blog.
The takeaway
We moved from fetch to XMLHttpRequest not because the new API is worse, but because, for streaming a flurry of small, time-sensitive events to every browser our customers use, XHR's progressive responseText is the simpler, more dependable contract. The events were always being sent — the job was making sure they showed up on time. If you're building streaming agent UIs, test on a real network behind real compression before you trust your transport, and remember that reliability often lives in the boring primitive.
Want to watch agents stream their work on your own queue? Start a 7-day free trial, no credit card required, connect your helpdesk, and run a test conversation — the status log is live.
Written by Abbas (Customer Support & AI, Macha) · Reviewed by Ankeet Guha (Co-founder & CTO) · Published 2026-06-24 · Last updated 2026-06-24.
Resolve tickets automatically with AI agents
Macha's AI agents work on top of the help desk you already use — no code.
Shopify
Stripe
Slack
Notion
Google Workspace
Confluence

