📻 Analogy
Server-Sent Events is a radio broadcast: you tune in once (open one HTTP request), and the station keeps transmitting updates to you for as long as you listen. You can’t talk back on that channel — for that you’d phone in (a separate request, or WebSockets). For live scores, notifications and dashboards, listening is all you need.
One connection, many events
The client makes a normal GET; the server keeps the response open and writes text/event-stream frames over time. The browser’s built-in EventSource parses them and auto-reconnects if the link drops:
graph LR C["client<br/>EventSource(/events)"] -->|"GET, stays open"| S["server"] S -->|"data: 1"| C S -->|"data: 2"| C S -->|"data: 3 …"| C
Each event is just text — data: lines ended by a blank line; optional event: (a name) and id: (for resume):
event: tick
id: 42
data: {"price": 101.5}
Stream events from a handler
An SSE handler sets the content type, then writes-and-flushes each event. We can run the exact handler in-process and capture the stream it produces:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// events streams three SSE frames over one long-lived response.
func events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, _ := w.(http.Flusher) // real servers push each event out now
for i := 1; i <= 3; i++ {
fmt.Fprintf(w, "event: tick\ndata: %d\n\n", i)
if flusher != nil {
flusher.Flush()
}
}
}
func main() {
// run the handler in-process; the recorder captures the event stream
rec := httptest.NewRecorder()
events(rec, httptest.NewRequest("GET", "/events", nil))
fmt.Println("content-type:", rec.Header().Get("Content-Type"))
fmt.Print(rec.Body.String())
}
On a real server
The playground above runs the handler in-process; in production it registers on a normal route and loops — pushing an event whenever new data arrives on a channel, and returning the moment the client disconnects (so a closed tab frees the goroutine).
Where does that channel come from? You need something that fans one source of updates out to every connected client — a pub/sub hub (the broker from the Pub/Sub pattern). broker.Subscribe() returns a new channel for this client, and broker.Unsubscribe closes it when they leave:
// Event is whatever you broadcast. The broker is a pub/sub hub whose
// Subscribe() hands each client its own channel of events.
type Event struct {
ID int
Name string
Data string
}
func sseHandler(broker *Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher := w.(http.Flusher)
updates := broker.Subscribe() // this client's own channel
defer broker.Unsubscribe(updates) // clean up when they disconnect
for {
select {
case ev := <-updates: // new data — send it as an event
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Name, ev.Data)
flusher.Flush() // push it out immediately
case <-r.Context().Done(): // client disconnected — stop
return
}
}
}
}
// wire it up: mux.Handle("GET /events", sseHandler(broker))
Broker is just a mutex-guarded set of subscriber channels with Subscribe/Unsubscribe/Publish methods — see the Pub/Sub pattern for a full implementation.
The browser consumes it with the built-in EventSource, which parses the frames and reconnects on its own:
const es = new EventSource("/events");
es.addEventListener("tick", (e) => console.log("tick:", e.data));
es.onerror = () => console.log("reconnecting…"); // the browser retries for you
Set timeouts carefully so the long-lived response isn’t cut off, and drain cleanly on shutdown (see graceful shutdown).
SSE vs WebSockets vs polling
| Direction | Transport | Reconnect | Use it for | |
|---|---|---|---|---|
| SSE | server → client | plain HTTP | built-in (EventSource) | feeds, notifications, dashboards |
| WebSockets | full-duplex | HTTP Upgrade | you handle it | chat, games, collaborative editing |
| Polling | client pulls | plain HTTP | n/a | simple, low-frequency updates |
⚠️ Flush, watch for proxies, and mind the connection budget
Forgetting Flush() is the #1 SSE bug — events buffer and the client sees nothing. Some proxies buffer text/event-stream too; disable it (e.g. X-Accel-Buffering: no for nginx). Each SSE client holds an open connection, so a server has a finite budget — bound it and shed load. SSE carries text only; base64 or JSON-encode binary. And remember the browser auto-reconnects and replays from Last-Event-ID, so make events idempotent or resumable.
See also
- WebSockets — when you need the client to send too.
- polling — the lowest-tech option for infrequent updates.
- Pub/Sub pattern — the broker that fans updates out to each subscriber.
- graceful shutdown — draining long-lived streams on exit.
Next: the simplest approach of all — polling.
Related topics
Full-duplex, persistent connections over one TCP socket — the HTTP Upgrade handshake, frames, ping/pong keepalives, and when to choose them over SSE or polling.
apisPollingWhen the client just asks again — short polling on a timer vs long polling that holds the request open, their costs, and when polling is the right boring choice.
httpHTTP ServerServing HTTP in net/http — handlers and HandlerFunc, the ResponseWriter and Request, the Go 1.22 method+pattern ServeMux with PathValue, decoding request bodies, and a production-shaped http.Server with timeouts.
Check your understanding
Score: 0 / 51. How does Server-Sent Events differ from WebSockets?
SSE is server-to-client only, runs over ordinary HTTP (text/event-stream), and the browser's EventSource auto-reconnects. WebSockets are bidirectional but need the Upgrade handshake. For dashboards/feeds/notifications, SSE is simpler.
2. What is the wire format of an SSE message?
Each event is UTF-8 text: optional `event:` and `id:` lines, one or more `data:` lines, then a blank line to dispatch it. Content-Type must be text/event-stream and the server flushes after each event.
3. Why must an SSE handler call Flush() (http.Flusher) after writing an event?
The response stays open and events trickle out over time, so you must flush each one or it buffers. Type-assert the ResponseWriter to http.Flusher and call Flush() after every event.
4. What is the id: field for, and how does the browser use it on reconnect?
Each event can carry id:. The browser remembers the last one and, after an auto-reconnect, sends it as the Last-Event-ID request header. Your handler can read that and replay only events the client missed — so design events to be resumable or idempotent.
5. An SSE stream works locally but events arrive in bursts (or never) behind nginx. Likely cause?
Reverse proxies often buffer responses, which defeats SSE's incremental delivery. Set X-Accel-Buffering: no (nginx) or the proxy's equivalent, ensure no gzip buffering, and keep flushing on the Go side — your Flush() only helps if nothing downstream re-buffers.
Comments
Sign in with GitHub to join the discussion.