Skip to content
gohacks.dev
Web

Server Sent Events in Go for Live Browser Updates Explained

Build a server sent events endpoint in Go with flushing, event ids, Last-Event-ID resume, heartbeats and clean shutdown, and see the wire format it sends.

7 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

When a page needs to show something that changes on the server, such as a build log, a job's progress or a dashboard counter, the reflex is a WebSocket. Most of the time that is more machinery than the job needs. The data only flows one way, from the server to the browser, and the browser already has a client for exactly that, EventSource, which speaks plain HTTP, reconnects by itself and resumes from where it left off. In Go the server side is about forty lines.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds that endpoint and prints the bytes it puts on the wire, so the format stops being folklore. It sits in the web cluster that ends in a production HTTP server, and the next articles cover the heavier alternatives, WebSockets with a real hub and long polling. Everything ran on Go 1.25.6 with the standard library only.

The wire format in one minute

The response is Content-Type: text/event-stream, kept open. The body is a sequence of events, each a few lines of field: value, terminated by an empty line:

Text
id: 7
event: progress
data: {"done":70}

Four fields matter. data: is the payload; several data: lines in one event are joined with newlines by the client. event: names the event, so the browser can listen with addEventListener("progress", ...); without it the event arrives as a generic message. id: sets the stream's last event ID. retry: tells the browser how many milliseconds to wait before reconnecting. A line starting with a colon is a comment, ignored by the client and perfect for a heartbeat. The full rules are in the WHATWG specification, and MDN's guide is the friendlier read.

Because the format is line based, a data: payload must not contain a raw newline. JSON from json.Marshal never does. Free text needs each line prefixed with data: , which is a small function and a classic bug when forgotten.

A handler that streams

The shape of the handler is always the same: set the headers, loop, write an event, flush, and leave when the client does.

main.goGo
package main

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"time"
)

var messages = []string{"queued", "started", "40 percent", "80 percent", "finished"}

func stream(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")
	w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer this response

	// A reconnecting browser sends the last id it saw; start after it.
	next := 0
	if v := r.Header.Get("Last-Event-ID"); v != "" {
		if n, err := strconv.Atoi(v); err == nil {
			next = n + 1
		}
	}

	fmt.Fprint(w, "retry: 2000\n\n")
	rc.Flush()

	tick := time.NewTicker(10 * time.Millisecond)
	defer tick.Stop()
	for ; next < len(messages); next++ {
		select {
		case <-r.Context().Done():
			return
		case <-tick.C:
		}
		fmt.Fprintf(w, "id: %d\nevent: status\ndata: %s\n\n", next, messages[next])
		if err := rc.Flush(); err != nil {
			return // the client is gone
		}
	}
}

// read prints the events a client sees; stopAfter cuts the connection early, like a flaky network would.
func read(url, lastID string, stopAfter int) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
	if lastID != "" {
		req.Header.Set("Last-Event-ID", lastID)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer res.Body.Close()
	fmt.Println("content-type:", res.Header.Get("Content-Type"))

	sc := bufio.NewScanner(res.Body)
	events := 0
	for sc.Scan() {
		line := sc.Text()
		if line == "" {
			events++
			if events == stopAfter {
				fmt.Println("-- connection dropped --")
				return
			}
			continue
		}
		fmt.Println(" ", line)
	}
	if err := sc.Err(); err != nil && err != io.EOF {
		fmt.Println("read error:", err)
	}
	fmt.Println("-- stream ended --")
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(stream))
	defer srv.Close()

	fmt.Println("first connection:")
	read(srv.URL, "", 3) // the retry preamble counts as event one
	fmt.Println("reconnect with Last-Event-ID: 1")
	read(srv.URL, "1", 0)
}
Text
first connection:
content-type: text/event-stream
  retry: 2000
  id: 0
  event: status
  data: queued
  id: 1
  event: status
  data: started
-- connection dropped --
reconnect with Last-Event-ID: 1
content-type: text/event-stream
  retry: 2000
  id: 2
  event: status
  data: 40 percent
  id: 3
  event: status
  data: 80 percent
  id: 4
  event: status
  data: finished
-- stream ended --

Three details in that handler are the ones people forget. First, rc.Flush() after every event: net/http buffers writes, and without a flush the client sees nothing until the buffer fills or the handler returns, which defeats the purpose. Second, the handler never blocks on anything that ignores r.Context(). When the browser closes the tab, the context is cancelled and the loop returns, so a thousand abandoned tabs do not leave a thousand goroutines behind. Third, a failed Flush means the connection is dead, and returning early saves the rest of the loop.

The second connection is the interesting one. Sending Last-Event-ID: 1 made the handler start at message two, so the client received exactly the events it had missed. EventSource does this automatically: it remembers the last id: it saw and sends the header on reconnect. Your job is to make ids meaningful, which in practice means they come from the data (a row ID, a sequence number), not a counter in memory that resets on deploy.

Resuming needs somewhere to resume from

The demo replays from a slice. A real feed has to be able to produce "everything after id 41" on demand. If events live in a database table with an increasing ID, that is one query. If they come from an in-memory broadcast, you need a bounded buffer of recent events and a decision for the case where the client's last ID has already fallen out of it: usually send a special reset event so the page refetches its full state, then continue live. Without that answer, Last-Event-ID gives a false sense of reliability, and the first long disconnect silently drops events.

Heartbeats and the proxy in the middle

Between your process and the browser sits at least one proxy, and proxies close connections that look idle. Nginx's proxy_read_timeout defaults to 60 seconds, and cloud load balancers vary from 30 seconds to several minutes. An event stream that is quiet for a while is idle as far as they can tell.

The cure is a comment line every 15 to 30 seconds:

Go
case <-heartbeat.C:
	fmt.Fprint(w, ": ping\n\n")
	if rc.Flush() != nil {
		return
	}

The browser ignores it. A heartbeat also doubles as dead-client detection, since the write fails when the peer has gone, even on a stream with no real events for an hour. For nginx in front of Go, X-Accel-Buffering: no (set in the handler above) and proxy_buffering off stop it from collecting your events into chunks. Compression in the path is the other buffering trap; a gzip middleware on this route will hold events back, so exclude it.

Timeouts, shutdown and connection limits

Streams are long-lived handlers, so the server settings written for short requests work against them.

A WriteTimeout on the server applies to the whole response, so a 30 second value kills your stream at 30 seconds. Rather than loosening it for everything, extend the deadline for this route with rc.SetWriteDeadline(time.Time{}), or push it forward after each event. The timeouts article explains why leaving the global value in place is still worth it.

During a deploy, Server.Shutdown waits for active requests, and a stream is an active request that never finishes. Register a hook with RegisterOnShutdown that closes a channel your handlers also select on; graceful shutdown for the HTTP server shows the pattern with measurements.

Browsers also limit connections. Over HTTP/1.1 a browser allows about six connections per host, and every open EventSource takes one for as long as it lives, so a user with six tabs open can starve your regular requests. HTTP/2 multiplexes streams over one connection and removes the problem, which is a good reason to terminate TLS with HTTP/2 in front of an SSE endpoint. Go's server negotiates it automatically over TLS.

When SSE is the wrong choice

EventSource cannot send custom headers, so Authorization: Bearer does not work; use a cookie or a short-lived token in the URL, and treat the URL as sensitive in logs. It carries text only, so binary payloads need encoding. And it is one directional. A chat room, a collaborative cursor or anything where the client talks back at high frequency belongs on a WebSocket; the client can still use ordinary POST requests next to an SSE stream for the occasional message, and I find that combination covers more cases than people expect.

For notifications, progress bars, live logs and dashboards, though, SSE is less code, survives proxies better and reconnects without you writing a line of client logic.

Also inNetworking
Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.