The dashboard that never loads
You are building a deployment dashboard. A user clicks "Start Build" and the system takes forty-five seconds to compile. The old approach is to make the browser poll the server every second, asking if the build is finished. Polling wastes bandwidth, adds latency, and feels sluggish. The user sees a spinner and wonders if the request stuck.
Server-Sent Events solve this. The server opens a persistent HTTP connection and pushes updates to the browser the moment they happen. The connection stays open. The browser listens. Updates arrive instantly. You get real-time feedback without the overhead of polling or the complexity of WebSockets.
One-way updates over HTTP
SSE is a standard part of HTML and HTTP. It requires no external libraries. The server sets a Content-Type header to text/event-stream. The browser detects this header and switches to EventSource mode. Data flows from server to client as a stream of text events.
The browser handles reconnection automatically. If the network drops, the browser waits and retries. It sends a Last-Event-ID header on reconnect so the server can resume from where it left off. This resilience comes for free.
SSE is unidirectional. The server talks; the client listens. If you need the client to send messages back to the server in real time, SSE is the wrong tool. Use WebSockets for bidirectional communication. If you only need server-to-client updates, SSE is lighter. It works over standard HTTP ports. Firewalls rarely block it. Proxy servers understand it.
Minimal stream
Here is the simplest SSE handler. It sets the required headers, asserts the flusher, and writes five events with a pause between each.
// sseHandler streams five events to the client, pausing between each.
func sseHandler(w http.ResponseWriter, r *http.Request) {
// SSE requires specific headers to tell the browser this is a stream, not a standard response.
w.Header().Set("Content-Type", "text/event-stream")
// Browsers cache responses aggressively. Disable caching so the stream stays fresh.
w.Header().Set("Cache-Control", "no-cache")
// Keep the connection open. Some proxies close idle connections; this header helps.
w.Header().Set("Connection", "keep-alive")
// The ResponseWriter buffers output by default. You must flush to send data immediately.
flusher, ok := w.(http.Flusher)
if !ok {
// If the writer doesn't support flushing, the stream won't work. Abort early.
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
for i := 0; i < 5; i++ {
// SSE format: "data: <payload>\n\n". The double newline signals the end of an event.
fmt.Fprintf(w, "data: %d\n\n", i)
// Push buffered data to the network now. Without this, the client waits for the handler to finish.
flusher.Flush()
time.Sleep(1 * time.Second)
}
}
Run this with a standard server setup. The handler blocks until the loop finishes. The client receives events one by one.
func main() {
http.HandleFunc("/sse", sseHandler)
http.ListenAndServe(":8080", nil)
}
Flush or wait. The buffer doesn't guess.
How the stream flows
When the request arrives, Go sets the headers. The Content-Type is the magic key. The browser sees text/event-stream and creates an EventSource object. It stops expecting a standard JSON or HTML response. It prepares to parse a stream of events.
The code asserts http.Flusher. This is an interface check. The standard http.ResponseWriter implements Flusher, but some wrappers or mocks might not. If the assertion fails, ok is false. The handler returns an error. This prevents a silent failure where data sits in a buffer forever.
Inside the loop, fmt.Fprintf writes to the buffer. The SSE spec defines the event format. Each event consists of fields followed by a blank line. The data: field carries the payload. The double newline \n\n signals the end of the event. A single newline continues the data field. Multi-line data is supported by repeating data: on each line.
Flush sends the buffered bytes to the client. Go's HTTP server buffers responses for performance. Small writes trigger expensive system calls. Buffering reduces the number of syscalls. SSE breaks this optimization by design. You accept the cost for real-time delivery. Without Flush, the client waits until the handler returns. The browser receives all five events at once after five seconds. That is a delayed response, not a stream.
Sleep simulates work. In a real application, you might wait for a channel, query a database, or process a queue. The handler blocks until the event is ready. This is efficient. One goroutine per connection handles the stream. The goroutine sleeps while waiting. It consumes no CPU.
Trust the double newline. The browser parser relies on it.
Real-world streaming with context
Real streams run until the client disconnects. You need to detect when the client closes the tab or navigates away. The request context provides this signal. r.Context() cancels when the request ends.
Here is a handler that streams timestamps until the client disconnects. It uses select to multiplex the context cancellation and a ticker.
// streamEvents streams timestamps until the client disconnects.
func streamEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "No flush support", http.StatusInternalServerError)
return
}
// Context provides a cancellation signal when the client closes the tab.
ctx := r.Context()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Client disconnected or request timed out. Stop streaming immediately.
return
case <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", time.Now().Format(time.RFC3339))
flusher.Flush()
}
}
}
Context is the kill switch. Respect it.
The select statement waits for two channels. ctx.Done() fires when the context cancels. ticker.C fires every second. If the client disconnects, ctx.Done() unblocks. The handler returns. The goroutine ends. The connection closes. This prevents goroutine leaks. If you ignore the context, the handler keeps running after the client leaves. The server holds the connection open. Resources accumulate.
defer ticker.Stop ensures the ticker is cleaned up when the function returns. Timers allocate resources. Stopping them releases the resources. This is a convention in Go. Always stop timers and tickers.
The handler extracts context from the request. Functions that take a context should accept it as the first parameter, conventionally named ctx. HTTP handlers are special. They receive context via r.Context(). If you extract logic into a helper function, pass ctx as the first argument.
// Helper functions accept context first.
func processUpdates(ctx context.Context, w http.ResponseWriter) {
// ...
}
This pattern makes cancellation explicit. Callers can pass a derived context with a deadline. The helper respects the deadline.
Pitfalls and silent failures
SSE streams can fail silently. The connection stays open, but no data arrives. Debugging requires checking both the server and the client.
If you skip the flusher check and the writer doesn't support flushing, the program panics. The type assertion returns a nil interface. Calling Flush on nil crashes with panic: runtime error: invalid memory address or nil pointer dereference. Always check ok.
If you write data and then call w.WriteHeader, the server rejects the call. Headers are sent implicitly on the first write. Calling WriteHeader after data causes http: superfluous response.WriteHeader call. Set headers before writing data.
Proxy servers can kill long-lived connections. Load balancers often have a read timeout of sixty seconds. If the server sends no data for sixty seconds, the proxy closes the connection. The stream dies. You can send periodic comments to keep the connection alive. SSE supports comments starting with a colon.
// Send a comment to keep the connection alive.
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
The browser ignores comments. The proxy sees activity. This prevents timeout closures. Alternatively, configure the proxy to allow longer timeouts for SSE endpoints.
Error handling is tricky. Once the stream starts, you cannot send an HTTP error code. The headers are already sent. You must send an event with error information, or close the connection. The client handles the error. This is a design trade-off. SSE is simple, but error recovery requires application logic.
The worst SSE bug is the silent stream that never flushes.
When to use SSE
Pick the right tool for the traffic pattern. Each approach has trade-offs in complexity, bandwidth, and compatibility.
Use SSE when you need one-way updates from server to client with minimal setup. Use WebSockets when the client must send messages back to the server in real time. Use HTTP polling when the server environment blocks long-lived connections or firewalls strip SSE headers. Use long polling when you need compatibility with very old browsers that lack EventSource support. Use plain HTTP responses when the data doesn't need to be live; a standard request-response cycle is simpler and scales better.
SSE is simple. WebSockets are heavy. Pick the tool that matches the traffic direction.