Macha

Reliable SSE Streaming: Why We Moved From fetch to XMLHttpRequest

Abbas, Customer Support & AI, Macha

Written by

Ankeet Guha, Co-founder & CTO, Macha

Reviewed by

Published July 29, 2026

Updated July 29, 2026

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`.

Reliable SSE Streaming: Why We Moved From fetch to 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.

The Transcribe Audio tool in Macha — the kind of long-running tool call whose live status (
The Transcribe Audio tool in Macha — the kind of long-running tool call whose live status ("Using Transcribe Audio") needs to stream to the operator while it runs.

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 gzip or brotli encoded, the compressor holds bytes until it has enough to compress a block efficiently. Your server can flush a tidy little data: Using Transcribe Audio\n\n and the compressor will happily sit on it. Sending Accept-Encoding: identity to 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.

A Macha agent's test-run screen — kicking off a real run is where the status log streams each tool call live, the behaviour the transport switch protects.
A Macha agent's test-run screen — kicking off a real run is where the status log streams each tool call live, the behaviour the transport switch protects.

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:

EventSourcefetch + ReadableStreamXMLHttpRequest
POST / request body❌ GET only
Custom headers (auth)
Auto-reconnect + Last-Event-ID✅ built-in❌ DIY❌ DIY
Incremental delivery modelPer-event (native)Per-read() (may batch)Growing responseText (per-progress)
Real-time small events✅ reliable⚠️ buffer-sensitive✅ reliable
Modern / future-facingStagnant✅ actively investedLegacy 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 onprogress on 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. No AbortController (XHR uses xhr.abort()), no promise-native flow, no request streaming. If you've standardised on fetch everywhere, a lone XHR is a wart in the codebase.
  • fetch can be made to behaveContent-Encoding: none (or identity), explicit flush() after every write, X-Accel-Buffering: no for 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 want fetch's power and EventSource-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.

Macha

About Macha

Macha is an AI agent platform that works on top of the help desk you already use — Zendesk, Freshdesk, Gorgias, or Front — and connects to the rest of your stack, even your own internal systems. Its AI agents resolve tickets and automate entire workflows end to end, all set up in plain English, no code. Learn more about Macha →

Zendesk
5.0 on Zendesk Marketplace

Loved by support teams worldwide

See what support teams are saying about Macha AI.

The application seems excellent to me! We are still testing, and we need support for some details and they were extremely efficient too!

Daniela Costa

Daniela Costa

Head of Support, Seabra

Macha has been a great addition to our support toolkit. It generates clear, well-organized responses that fit naturally into our workflow. One feature we particularly appreciate is its ability to automatically reply in the same language as the ticket.

Marius F

Marius F

Support Head, Zentana

We've been using Macha for a little while now and it's been really great addition so far! It's powerful, convenient, and makes getting work done a lot easier for our agents.

Alexander Wedén

Alexander Wedén

Head of Support

Support team is very helpful and responsive. Really enjoy how lightweight this is within Zendesk itself vs other more intrusive tools.

Cathleen Wright

Cathleen Wright

Zendesk Admin, Cortex IO

So far it's pretty good! Our queries are a little nuanced, so we can't always use it, but it's got enough utility for us. It can even incorporate our bilingual country with greetings in a second language.

Jae Oliver

Jae Oliver

Head of Support, Wise

Really enjoying using Macha, it has made a noticeable difference to our support team in a short amount of time. I really like the ticket summary feature, saves us a lot of time.

Harry Jackson

Harry Jackson

Head of Support, Crumb

Macha AI is a great addition to my workspace! It's powerful, convenient, and it really makes productivity so much easier for our agents!

Dave G

Dave G

Head of Support, Cyber Power Systems

Very impressed! AI integration for Zendesk has certainly come a long way and Macha seems to set the standard for now. This will for sure save lot of time in our support team.

Pauli Juel

Pauli Juel

Head of CS, Dokument24

Macha has been working great for us so far! The auto-responses are accurate and our resolution time has dropped significantly.

Lana T

Lana T

Zendesk Admin, Swotzy

Macha AI is a great addition. The knowledge base feature means our agents always have the right answers at their fingertips.

Mischa Wolf

Mischa Wolf

Head of Support, Topi

We're enjoying this integration so far. It's made our support team more efficient and our customers get faster responses.

Paula G

Paula G

Head of Customer Support, Xly Studio

The team enjoys using it. It saves considerable time on common questions and the integration options are excellent.

Kilian Leister

Kilian Leister

Support Head, Didriksons

Ready to supercharge your team with AI?

Get started in minutes. Connect your tools, configure your agents, and let AI handle the rest.

500 free credits · no time limit, no credit card