When we talk about real-time applications, the first thing that usually comes to mind is WebSockets. After digging into the topic, though, I found that there are several ways to build real-time behavior, and each one fits a different use case: one-way data, two-way data, peer-to-peer communication, and so on.
In many situations, WebSockets add unnecessary infrastructure overhead. You pay the cost of stateful connections, deal with load balancer concerns such as sticky sessions, and handle heartbeats so the connection does not quietly die.
This article focuses on SSE (Server-Sent Events) and why, in many scenarios, it is the simpler and smarter option, especially when all you need is to stream data from the server to the client.
This article is inspired by an excellent talk by Azim Pulat (ex-Google) at DevFest Warsaw 24. If you want to go deeper, I highly recommend watching his talk here.
Why streaming?
When you ask Claude or ChatGPT a question, you can see the answer appear word by word.
The question is: why does the server not wait until the whole answer is ready and then send it in one HTTP response?
The short answer is user experience. Imagine a user waiting 10 seconds in front of a loading spinner without knowing whether the system is working. That is a bad experience. A better approach is to send each piece of the answer as soon as it is ready. The technique that makes this kind of streaming possible with very little engineering overhead is Server-Sent Events (SSE).
What is Server-Sent Events (SSE)?
SSE is a regular HTTP connection that stays open. For the browser to understand that this is not a normal response, the server needs to follow a few protocol rules.
1. Required headers
The server must send these headers for the stream to work correctly:
-
Content-Type: text/event-stream: tells the browser to treat the response as an event stream. -
Cache-Control: no-cache: prevents proxies such as Nginx from buffering the response and sending everything at once. -
Connection: keep-alive: keeps the TCP connection open.
2. Data format
SSE is a text-based protocol. The data must follow a specific format, otherwise the browser's EventSource API will not read it correctly:
-
Each message starts with
data:followed by the payload. -
Each message ends with two newline characters:
\n\n. Without them, the browser will keep waiting for the rest of the message and will not emit it.
data: first word\n\n
data: second word\n\n
data: third word\n\nBuilding SSE with Go and React
To understand the mechanics, we can build a small demo: a Go server that mimics an AI model generating tokens one by one, and a React frontend that renders those tokens as they arrive.
1. The server (Go)
To create an SSE endpoint in Go, you need three core pieces:
// Handler for "/event".
func event(w http.ResponseWriter, _ *http.Request) {
// Tell the browser this response is an event stream.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
tokens := []string{"hey", "how", "is", "it", "going"}
for _, token := range tokens {
// Standard SSE format: data: <content>\n\n
fmt.Fprintf(w, "data: %s\n\n", token)
// Flush sends the buffered bytes to the client immediately.
w.(http.Flusher).Flush()
time.Sleep(time.Millisecond * 500)
}
// Send a custom event so the client knows the stream is complete.
fmt.Fprint(w, "event: done\ndata: end_of_stream\n\n")
w.(http.Flusher).Flush()
}How it works:
-
text/event-streamtells the browser to keep the connection open and read the response incrementally. -
Flush()is the key. Go, like many runtimes, buffers response data.Flush()forces the server to send whatever is currently in the buffer to the client immediately, without waiting for the loop to finish.
2. The client-side gotcha: why does the browser not show the data?
Even after implementing the server correctly, you might not see the real-time effect if you test the endpoint directly in the browser.
If you open the endpoint in the address bar, you will often see all the tokens appear at once. The problem is not the server. It is the client's buffering strategy:
-
Response-oriented clients (browsers/Postman): treat HTTP as a full payload. They often wait for the request to complete before rendering the response.
-
Stream-oriented clients (cURL/EventSource): read chunks as soon as they reach the network interface, without waiting for the connection to close.
To confirm that the server side is working, test it with this command:
curl -N http://localhost:8080/event
The -N flag disables buffering in cURL.
3. Building the client (React)
In JavaScript, we consume SSE through the EventSource API:
useEffect(() => {
const sse = new EventSource("http://localhost:8080/event");
// Receive regular messages.
sse.onmessage = (e) => {
setTokens((prev) => [...prev, e.data]);
};
// Receive the custom completion event sent by the server.
sse.addEventListener("done", () => {
sse.close();
});
// Close the connection when the component unmounts.
return () => sse.close();
}, []);Why is sse.close() important?
EventSource is designed for live feeds that may never end. If the server closes the connection from its side, the browser assumes there was a network issue and automatically tries to reconnect. That can cause the client to request the same data again. To avoid this loop, the server sends a custom event (event: done), and the client calls close() to terminate the connection cleanly.
Why SSE instead of WebSockets?
For AI streaming, SSE is often the more pragmatic choice for several engineering reasons:
-
It runs over HTTP: SSE works over regular HTTP. You do not need a protocol upgrade like you do with WebSockets.
-
It is unidirectional by design: In a chatbot flow, the client sends one request, and the server sends many response chunks. That one-way model is exactly what SSE is built for.
-
Automatic reconnection: Browsers include a built-in reconnection mechanism for
EventSource, so you do not have to write it yourself. -
It is lightweight: There is no extra WebSocket handshake overhead.
WebSockets and WebRTC
SSE is a good fit for LLM applications, but it has a clear limitation: it is one-way. The server speaks and the client listens. When you need a more interactive application, such as chat or multiplayer games, you need a different protocol.
WebSockets: when you need two-way communication
WebSocket starts as a normal HTTP connection and then upgrades to an independent ws:// or wss:// protocol. This allows it to:
-
Send data in both directions at the same time.
-
Support binary data, not just text.
WebRTC: when latency becomes critical
For video and audio calls like Google Meet, even WebSockets are not enough. Sending all data through a server adds latency. This is where WebRTC comes in. It lets browsers communicate directly with each other through peer-to-peer connections, usually over UDP instead of TCP, which helps avoid delays caused by packet loss and head-of-line blocking.
In another article, I will try to explain WebSockets and WebRTC in more depth.
Quick comparison
| SSE | WebSocket | WebRTC | |
|---|---|---|---|
| Data direction | Server to client (unidirectional) | Bidirectional | Client to client (peer-to-peer) |
| Data type | Text-based | Text and binary | Video, audio, and arbitrary data |
| Best fit | LLM streaming, live news, notifications | Chat apps, games, collaborative tools | Video calls, live P2P streaming |
Conclusion
In HTTP/1.1, browsers are usually limited to 6 TCP connections per domain. If you want your app to scale with many streams, make sure you are using HTTP/2, which solves this through multiplexing: many SSE streams over one connection.
The right transport depends on the problem you are solving.
Do not over-engineer it. Start with SSE. If the client also needs to talk to the server continuously, then consider WebSockets.
The code for the demo is available on GitHub.