Server-Sent Events
A long-lived HTTP response the server keeps writing to, flushed event by event and read by the browser as it arrives.
w.Header().Set("Content-Type", "text/event-stream"); fmt.Fprintf(w, "data: %s\n\n", msg); http.NewResponseController(w).Flush()Server-Sent Events, usually written SSE, is an ordinary HTTP response that the server never finishes. The handler writes a line, pushes it out to the client, and keeps the connection open. The browser reads each line as it arrives.
It exists because the alternative is asking. Without it a page has to request the same URL every few seconds to find out whether anything changed. With SSE the server speaks when it has something to say and the page listens.
The wire format
Set the header Content-Type: text/event-stream. Then write one or more lines and end the message with a blank line. data: hello followed by an empty line is one complete event. Three other field names are allowed. event: names the message, id: labels it, and retry: sets how many milliseconds the browser waits before reconnecting. A line starting with a colon is a comment and the browser ignores it.
Flushing
Go buffers what a handler writes. Nothing reaches the client until the buffer is flushed or the handler returns. http.NewResponseController(w).Flush(), added in Go 1.20, does the flush, and you call it after every event. When the client disappears, the request context is cancelled, so r.Context().Done() is your signal to return.
What trips people up
SSE runs one way only, server to browser. The browser sends data back with a normal request. Compression middleware and proxies buffer the stream and make a working handler look broken; send X-Accel-Buffering: no for nginx and keep gzip off this route. Over HTTP/1.1 a browser allows about six connections per host, and each open stream uses one of them.
Example
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
)
// stream writes three events and flushes after each one, then returns.
func stream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
rc := http.NewResponseController(w) // Go 1.20 and later
for i := 1; i <= 3; i++ {
fmt.Fprintf(w, "data: tick %d\n\n", i) // the blank line ends the event
if err := rc.Flush(); err != nil {
return // nothing is listening any more
}
}
}
func main() {
// A real program would call http.ListenAndServe and you would read the
// stream with: curl -N http://localhost:8080/events
srv := httptest.NewServer(http.HandlerFunc(stream))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
panic(err)
}
}
// Output:
// data: tick 1
//
// data: tick 2
//
// data: tick 3
When to use it
- Push live prices into a dashboard without the page polling every second.
- Report the progress of a long import back to the browser that started it.
- Tail deployment logs into an admin page as they are written.
More examples
A stream with event names, ids and a heartbeat
The shape a real endpoint takes. It waits on three things at once: a cancelled request context, which means the client is gone; a new update to send; and a ticker that writes a comment line every fifteen seconds so proxies do not see an idle connection. The id: field is what lets the browser resume after a dropped connection, and X-Accel-Buffering tells nginx not to hold the bytes back. Reach for this when the stream is long-lived rather than a short burst.
package stream
import (
"fmt"
"net/http"
"time"
)
type update struct {
id int
body string
}
// Events streams updates until the client goes away.
func Events(updates <-chan update) 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("X-Accel-Buffering", "no") // stops nginx buffering the stream
rc := http.NewResponseController(w)
fmt.Fprint(w, "retry: 2000\n\n") // how long the browser waits before reconnecting
beat := time.NewTicker(15 * time.Second)
defer beat.Stop()
for {
select {
case <-r.Context().Done():
return // the tab was closed, or the network dropped
case u := <-updates:
// id: lets the browser resume with a Last-Event-ID header.
fmt.Fprintf(w, "id: %d\nevent: price\ndata: %s\n\n", u.id, u.body)
case <-beat.C:
fmt.Fprint(w, ": keep-alive\n\n") // a comment line; the browser ignores it
}
if err := rc.Flush(); err != nil {
return // the connection is gone
}
}
}
}Flushing: the longhand and the shorthand
Two ways to say the same thing. The longhand asks whether the ResponseWriter also has a Flush method and calls it, which is how every SSE handler was written before Go 1.20. The shorthand, http.NewResponseController(w).Flush(), does that same check and returns an error instead of a boolean, and it keeps working when middleware has wrapped the ResponseWriter in a type of its own. Prefer the shorthand in new code; recognise the longhand because older code is full of it.
package stream
import (
"errors"
"net/http"
)
// Longhand, and the only way before Go 1.20: ask the ResponseWriter whether it
// can flush, with a type assertion.
func flushLonghand(w http.ResponseWriter) error {
f, ok := w.(http.Flusher)
if !ok {
return errors.New("this ResponseWriter cannot flush")
}
f.Flush()
return nil
}
// Shorthand, Go 1.20 and later. The same work, and it still finds Flush when
// middleware has wrapped the ResponseWriter in another type.
func flushShorthand(w http.ResponseWriter) error {
return http.NewResponseController(w).Flush()
}
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
if _, err := w.Write([]byte("data: ready\n\n")); err != nil {
return
}
if err := flushShorthand(w); err != nil {
// http.ErrNotSupported means this writer has no Flush at all.
if errors.Is(err, http.ErrNotSupported) {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
}
return
}
if err := flushLonghand(w); err != nil {
return
}
}The browser side
SSE needs no client library: EventSource is part of the browser. Messages without an event: field arrive at onmessage, and messages written with event: price arrive at a listener registered under that name. The reconnect is automatic, and the browser sends the last id it received in a Last-Event-ID header, which is why writing ids is worth the effort. Use this page to check what your handler is actually emitting.
// The browser side of Server-Sent Events. EventSource is built in: no library.
const es = new EventSource("/events");
// Messages with no event: field arrive here.
es.onmessage = (e) => {
console.log("data:", e.data, "id:", e.lastEventId);
};
// Messages written as "event: price" arrive on a listener of that name.
es.addEventListener("price", (e) => {
document.querySelector("#price").textContent = e.data;
});
// EventSource reconnects on its own after a dropped connection, and sends the
// last id it saw in a Last-Event-ID request header. onerror fires meanwhile.
es.onerror = () => {
console.log("disconnected; the browser will retry");
};
// Closing is explicit. Without this the connection outlives the page's use for it.
// es.close();
Discussion