Asking AI on My Blog: SSE Streaming Through Plain fetch
EventSource can't POST. The blog's Ask AI answers run through a plain fetch, a hand-rolled SSE parser, and one CRLF trap worth remembering.
Typed a question into the search dialog, hit the Ask AI button, and watched the answer appear piece by piece, like someone typing it live. That was the whole feature: ask in the search box, get an answer that streams in. What I didn't expect was how little I'd need to build it. No WebSocket, no client library. Just a plain fetch call and a loop reading chunks off the wire.
The server side is a FastAPI endpoint, POST /articles/ask. It takes a JSON body with the question and locale, builds a prompt grounded in the blog's own articles, and returns a StreamingResponse fed by an async generator, which streams the response body as it yields [3]. That fits, because the LLM itself streams tokens back, and modern LLM APIs let you start printing output while the rest is still generating [4]. No point waiting for the full answer just to send it in one lump.
My wrong guess first
When I sat down to write the frontend, my first thought was EventSource. It's the browser's built-in interface for server-sent events, ships everywhere, handles reconnects. Done, right?
No. EventSource only speaks GET. There is no way to set the method or attach a request body, and server-sent events over EventSource are strictly one-directional, server to client [1][2]. My endpoint needs a POST with a JSON body. So EventSource was out before I wrote a line.
The fix is honestly less code than it sounds: skip EventSource entirely, use a regular fetch with Accept: text/event-stream, and read the response body as a stream yourself. res.body.getReader() gives you a reader, TextDecoder turns bytes into text, and you append everything to a buffer. The SSE format is just text frames separated by blank lines, so each loop checks whether the buffer contains that separator. If yes, one complete frame: parse it, emit it to the UI, clear the buffer, keep reading.
The events come in a fixed order. First “sources”, the list of articles the answer is grounded in. Then a series of “delta” events, each a chunk of text to append. Then “followups” with suggested next questions, and finally “done”. If something breaks, an “error” event carries the message.
The CRLF trap
One bug cost me more time than everything else combined. My frame splitter looked for a doubled LF as the blank-line separator. It never fired. The buffer kept growing, no frame was ever considered complete, and the UI sat there with nothing.
The reason: sse-starlette, the library producing the server side, ends each frame with CRLF. So the actual separator on the wire is CRLF CRLF, not a doubled LF. The reliable move is to normalize the buffer to LF first, then split. One string replace, and everything downstream behaves.
Worth knowing before you debug this at 1am like I did. The symptom is maddening precisely because nothing fails: no exception, no console error, just silence while bytes pile up in a buffer.
Why the one-way thing is fine
The part I overthought the longest: SSE is a one-way connection, the client can't send events to the server mid-stream [1]. Doesn't an AI chat need to be two-way? Doesn't it feel wrong to skip WebSockets?
No. Think about the actual shape of this feature. The client sends one request with the question. The server sends back an answer, in pieces, in order. That's it. Nothing the client needs to say mid-stream. One HTTP connection that streams from server to client covers the entire requirement [2]. WebSockets would give me bidirectional messaging I'd never use, plus reconnect logic, plus a different protocol to babysit.
Two extra things came free because it's all plain HTTP. Rate limiting is 5 requests per minute per IP, checked before the stream starts. Answers get cached for 24 hours, so repeated questions skip the LLM entirely. And if the LLM router is switched off in the environment config, the endpoint returns 501 with an ask_disabled code instead of pretending to work.
So: search dialog, one POST, one streamed response, parsed by hand. Less code than any library-based approach I considered, and I understand every byte of it.