Go

Implement Server-Sent Events (SSE) in net/http. How do http.ResponseController, flushing and WriteTimeout interact?

Question 474HardGo 1.22 to 1.25

SSE is a long-lived text/event-stream response where the server writes data: ...\n\n frames and flushes each one. Two things usually break it: (1) a middleware wrapper hides http.Flusher, and (2) the server's WriteTimeout kills the stream after N seconds. http.ResponseController (Go 1.20) solves both: it walks Unwrap() chains to find a writer that supports Flush, SetWriteDeadline, SetReadDeadline, Hijack and EnableFullDuplex (Go 1.21), returning http.ErrNotSupported otherwise.

func events(w http.ResponseWriter, r *http.Request) {
	rc := http.NewResponseController(w)
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")

	t := time.NewTicker(time.Second)
	defer t.Stop()
	for id := 0; ; id++ {
		// Extend the server's WriteTimeout for this long-lived response.
		if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
			return
		}
		select {
		case <-r.Context().Done():
			return // client disconnected
		case now := <-t.C:
			fmt.Fprintf(w, "id: %d\ndata: %s\n\n", id, now.Format(time.RFC3339))
			if err := rc.Flush(); err != nil {
				return // http.ErrNotSupported if no writer in the chain can flush
			}
		}
	}
}

Talking points: a rolling deadline (reset per event) still detects dead clients; http.TimeoutHandler and gzip middleware buffer output and break streaming; reverse proxies (nginx) need buffering disabled (X-Accel-Buffering: no); each open stream holds a goroutine and FD, so cap concurrent subscribers; on HTTP/1.1 browsers limit ~6 connections per origin, HTTP/2 multiplexes them.

More on Standard Library, HTTP & Systems Design in Go

All 35 Standard Library, HTTP & Systems Design in Go questions