Server Sent Events API Design in Go for Browser Clients
Design a server-sent events endpoint in Go that survives reconnects: event ids, Last-Event-ID replay, retry hints, heartbeats and a reset signal for gaps.

Server-sent events are the quiet workhorse of live web pages. A browser opens one long HTTP response, the server writes text frames into it, and EventSource turns them into events. Order status, deploy logs, notification badges, progress bars. It needs no special protocol, works through most proxies and reconnects by itself.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Most tutorials, including the one on this site, show how to write the first event. The part that decides whether the feature works in production is the contract around it: what an event is called, how a client that lost its connection catches up, how the server says "you missed too much", and what keeps a proxy from killing an idle stream. That is API design, and this article does it in Go with the standard library. It ran on Go 1.25.6. For two-way traffic, use WebSockets instead; SSE is one direction only.
The wire format in thirty seconds
An SSE response has Content-Type: text/event-stream and consists of frames separated by blank lines. Each frame is lines of field: value:
data:is the payload. Severaldata:lines in one frame are joined with newlines.event:names the event. Without it the browser fires the genericmessageevent.id:sets the last event id, which the browser remembers.retry:sets the reconnection delay in milliseconds.- A line starting with
:is a comment and is ignored.
That is the entire protocol. The HTML living standard defines it in a couple of pages.
Reconnection is the feature
When an EventSource connection drops, the browser reconnects after the retry delay and sends a Last-Event-ID header containing the last id: it saw. The browser does the remembering; the server has to do something with it. If every event has an id and the server keeps a short log, a reconnect goes unnoticed: the server replays what the client missed and then continues live.
Without ids, every dropped connection loses events silently. Users of a phone on a train will find that out for you.
A hub with a replay log
The lab keeps a bounded log of the last hundred events and a set of subscribers. The one subtle part is subscribe: it takes the missed events from the log and registers the live channel under the same lock. If those were two steps, an event published between them would be missed by both.
package main
import (
"bufio"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"time"
)
type event struct {
id int
name string
data string
}
// hub keeps a bounded log so a client that reconnects can be caught up from Last-Event-ID.
type hub struct {
mu sync.Mutex
log []event
next int
subs map[chan event]bool
}
func newHub() *hub { return &hub{next: 1, subs: map[chan event]bool{}} }
func (h *hub) publish(name, data string) {
h.mu.Lock()
defer h.mu.Unlock()
e := event{id: h.next, name: name, data: data}
h.next++
h.log = append(h.log, e)
if len(h.log) > 100 {
h.log = h.log[1:]
}
for c := range h.subs {
select {
case c <- e:
default: // a slow client misses live events; it can reconnect and replay from its last id
}
}
}
// subscribe returns the events after lastID and a channel for live ones, registered under one lock so none fall between.
func (h *hub) subscribe(lastID int) ([]event, chan event, bool) {
h.mu.Lock()
defer h.mu.Unlock()
var missed []event
gap := len(h.log) > 0 && lastID > 0 && h.log[0].id > lastID+1 // the oldest event we kept is newer than the client expects
for _, e := range h.log {
if e.id > lastID {
missed = append(missed, e)
}
}
c := make(chan event, 16)
h.subs[c] = true
return missed, c, gap
}
func (h *hub) unsubscribe(c chan event) {
h.mu.Lock()
delete(h.subs, c)
h.mu.Unlock()
}
func write(w http.ResponseWriter, e event) {
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.id, e.name, e.data)
w.(http.Flusher).Flush()
}
func (h *hub) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // nginx would otherwise hold the stream in a buffer
lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
missed, c, gap := h.subscribe(lastID)
defer h.unsubscribe(c)
fmt.Fprint(w, "retry: 2000\n\n") // tell the browser how long to wait before reconnecting
if gap {
fmt.Fprint(w, "event: reset\ndata: history truncated, reload state\n\n")
}
for _, e := range missed {
write(w, e)
}
w.(http.Flusher).Flush()
heartbeat := time.NewTicker(25 * time.Millisecond)
defer heartbeat.Stop()
for {
select {
case e := <-c:
write(w, e)
case <-heartbeat.C:
fmt.Fprint(w, ": keep-alive\n\n") // a comment line: ignored by EventSource, resets idle timers on proxies
w.(http.Flusher).Flush()
case <-r.Context().Done():
return
}
}
}
// readEvents reads frames until n data-carrying events have arrived or the stream ends.
func readEvents(ctx context.Context, url, lastID string, n int) []string {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if lastID != "" {
req.Header.Set("Last-Event-ID", lastID)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return []string{err.Error()}
}
defer resp.Body.Close()
var out []string
var id, name string
sc := bufio.NewScanner(resp.Body)
for sc.Scan() && len(out) < n {
line := sc.Text()
switch {
case strings.HasPrefix(line, "id: "):
id = line[4:]
case strings.HasPrefix(line, "event: "):
name = line[7:]
case strings.HasPrefix(line, "data: "):
out = append(out, fmt.Sprintf("id=%s %s %q", id, name, line[6:]))
id, name = "", ""
}
}
return out
}
func main() {
h := newHub()
srv := httptest.NewServer(http.HandlerFunc(h.handler))
defer srv.Close()
for i := 1; i <= 5; i++ {
h.publish("order.updated", fmt.Sprintf(`{"order":%d,"status":"packed"}`, i))
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
fmt.Println("first connection, no Last-Event-ID (gets the whole log):")
for _, l := range readEvents(ctx, srv.URL, "", 5) {
fmt.Println(" ", l)
}
h.publish("order.updated", `{"order":6,"status":"shipped"}`)
h.publish("order.updated", `{"order":7,"status":"shipped"}`)
fmt.Println("reconnect with Last-Event-ID: 5 (only what was missed):")
for _, l := range readEvents(ctx, srv.URL, "5", 2) {
fmt.Println(" ", l)
}
for i := 8; i <= 130; i++ {
h.publish("order.updated", fmt.Sprintf(`{"order":%d}`, i))
}
fmt.Println("reconnect with Last-Event-ID: 7 after the log was trimmed:")
for _, l := range readEvents(ctx, srv.URL, "7", 2) {
fmt.Println(" ", l)
}
}first connection, no Last-Event-ID (gets the whole log):
id=1 order.updated "{\"order\":1,\"status\":\"packed\"}"
id=2 order.updated "{\"order\":2,\"status\":\"packed\"}"
id=3 order.updated "{\"order\":3,\"status\":\"packed\"}"
id=4 order.updated "{\"order\":4,\"status\":\"packed\"}"
id=5 order.updated "{\"order\":5,\"status\":\"packed\"}"
reconnect with Last-Event-ID: 5 (only what was missed):
id=6 order.updated "{\"order\":6,\"status\":\"shipped\"}"
id=7 order.updated "{\"order\":7,\"status\":\"shipped\"}"
reconnect with Last-Event-ID: 7 after the log was trimmed:
id= reset "history truncated, reload state"
id=31 order.updated "{\"order\":31}"What the run shows
First connect. With no Last-Event-ID, the client received all five events in the log, ids 1 to 5, each with its event name and JSON payload.
Reconnect with an id. After two more events were published, a connection that sent Last-Event-ID: 5 received exactly ids 6 and 7. Nothing was repeated and nothing was lost. This is the property worth testing in your own code, because it is what your users depend on.
Reconnect after the log was trimmed. The lab then published 123 more events, so the log of 100 had dropped the oldest ones. A client claiming id 7 asked for events the server no longer has. The handler noticed that the oldest retained id (31) is more than one past the client's last id, so it sent a reset event first, then continued with what it had. A well-behaved client treats reset as "my local state is stale: refetch it from the REST API, then carry on". Silently sending id 31 would leave a hole that the user never learns about.
That gap check is three lines and it is the difference between an eventually consistent view and a quietly wrong one.
Choices inside the handler
Ids. Use a monotonically increasing number per stream, or per user if streams are per user. Do not use timestamps, which collide, or random IDs, which cannot be compared to find "everything after". If events come from a database table, its sequence column is a natural id. Remember that ids are strings on the wire and only the server needs to interpret them.
The retry hint. retry: 2000 tells the browser to wait two seconds. Sending it first makes the client's behaviour a server decision. If you are deploying a new version and want clients to spread out, send a larger value, or add jitter in the code that serves it. Without a hint, browsers pick their own delay, usually a few seconds.
Heartbeats. Load balancers and proxies close connections that carry no bytes for a minute or so. A comment line every 15 to 30 seconds keeps the stream alive and costs nothing to the client. The lab uses 25 ms to keep the demonstration quick; use seconds in production. A heartbeat also lets the server notice dead peers sooner, because a failing write returns an error.
Buffering. Some proxies buffer responses until they reach a size, which makes a live stream arrive in lumps. X-Accel-Buffering: no tells nginx not to; other proxies have their own settings. Always flush after each frame, which in Go means calling Flush on the http.ResponseWriter through the http.Flusher interface, or the newer http.NewResponseController(w).Flush().
Slow clients. Never block the publisher on one subscriber. The hub uses a buffered channel per client and drops live events when the channel is full. The dropped client will notice the gap in ids on its next reconnect and replay. A hard cap on how much you keep per client is more important than fairness.
Closing. The handler returns when r.Context().Done() fires, which happens when the client disconnects or the server shuts down. Remember http.Server.Shutdown waits for active handlers, and a stream never finishes on its own. Cancel the base context given to the server during shutdown, or streams will hold the process until the timeout. See graceful shutdown for the pattern.
Designing events
Treat the stream as an API with the same care as a REST resource.
- Name events like a vocabulary:
order.updated,order.cancelled. Clients register a listener per name withaddEventListener, and a stable list is easier to document and deprecate than payload sniffing. - Keep payloads small and self-describing. Send the resource id and the changed fields, or just the id and let the client fetch the resource. A notification with the id is the safest choice when payloads change often.
- Version the payloads. An event in a log lives longer than a request. Adding fields is fine; changing meaning is a new event name. The rules are the same as in API deprecation.
- Document
reset. Tell clients exactly what to do when it arrives.
Authentication
EventSource cannot set custom headers, which surprises people who expect a bearer token. The options are a same-site session cookie, which works and gets the usual CSRF considerations (SSE is a GET, so it cannot change state), a short-lived token passed in the query string, or fetch with a streaming body reader, which can send any header at the price of writing the parsing yourself. If you use a query token, make it single-purpose and short-lived, because URLs end up in logs. API authentication discusses the trade-offs between those token types.
Authorise per event too. A stream is long-lived: a user whose access is revoked at minute five should not keep receiving at minute fifty. Either close the stream on revocation or check permissions when publishing to each subscriber.
Limits to plan for
Browsers limit the number of concurrent HTTP/1.1 connections to one host, typically six, and each open EventSource uses one. Opening a stream per tab can block regular requests. HTTP/2 multiplexes streams over one connection and lifts the problem, which is another reason to terminate TLS with HTTP/2 enabled. On the server, every open stream is a goroutine plus a socket, and tens of thousands are fine in Go if the per-client state is small. Check your file descriptor limit before you find out in production.
When the clients need to send as well as receive, or you need binary frames, move to WebSockets. When the data is a one-off long job, a job resource with polling is simpler. For everything in between, SSE with ids, a log and a reset signal is a small, honest design. The MDN guide to server-sent events covers the browser side in detail.


