Real-Time Dashboards with SSE in Go
The metrics page on this site streams live system telemetry to every connected viewer in real time. Uptime, heap allocation, GC cycles, goroutine counts, request rates — all updating every second with no page reloads and no JavaScript framework. Just Go's standard library, Server-Sent Events, and HTMX.
The interesting part isn't the streaming itself — SSE is simple. The interesting
part is what happens when you call runtime.ReadMemStats() and realize you
can't do it on every request.
runtime.ReadMemStats Is Expensive
Go's runtime.ReadMemStats gives you everything: heap in use, total allocated,
system memory, GC cycles, pause durations. It's the easiest way to build a live
dashboard without pulling in Prometheus or an external agent. But there's a
catch buried in the docs:
ReadMemStats populates m by reading data from the runtime. It does this by stopping the world.
Stop-the-world means every goroutine pauses while ReadMemStats does its work.
On a small site this is barely measurable, but if you're calling it once per
viewer per second, you're stopping the world N times per second. Two viewers is
fine. Twenty is noticeable. That's not a scaling model — it's a ceiling.
The first version of the dashboard called ReadMemStats directly inside the SSE
handler. One goroutine per viewer, each calling ReadMemStats on its own tick.
It worked, but it was doing redundant work. Every viewer gets the same numbers.
Why compute them N times?
The Cached Snapshot Pattern
The fix is simple: one goroutine collects metrics once per second and stores
the result behind an atomic.Pointer. Every viewer reads the same cached
snapshot. One stop-the-world pause per second, regardless of viewer count.
type Collector struct {
StartTime time.Time
cachedMetrics atomic.Pointer[MetricData]
// ... counters, sessions, etc.
}
func (c *Collector) fetchMetrics() {
snap := c.snapShot()
c.cachedMetrics.Store(&snap)
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
snap := c.snapShot()
c.cachedMetrics.Store(&snap)
}
}
snapShot() is where the expensive call lives:
func (c *Collector) snapShot() MetricData {
data := MetricData{}
data.Uptime = time.Since(c.StartTime)
data.TotalRequests = atomic.LoadUint64(&c.requestCount)
data.ReqsPerSecond = float64(data.TotalRequests) / data.Uptime.Seconds()
data.GoRoutineCount = runtime.NumGoroutine()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
data.MemoryAlloc = memStats.Alloc
data.TotalAlloc = memStats.TotalAlloc
data.SystemMem = memStats.Sys
data.GCCycles = memStats.NumGC
// ... more fields
return data
}
The public SnapShot() method just reads the pointer:
func (c *Collector) SnapShot() MetricData {
if p := c.cachedMetrics.Load(); p != nil {
return *p
}
return MetricData{}
}
atomic.Pointer is lock-free — readers never block each other, and the writer
never blocks readers. The data is at most one second stale, which is fine for a
dashboard. (The concurrency story around the other fields in this collector
turned out to be more interesting than I expected — that's covered in
the follow-up post.)
Writing the SSE Handler in Go
SSE is a one-way stream over HTTP. The server writes data: lines, the client
reads them. No upgrade handshake like WebSockets, no bidirectional framing. In
Go, you need two things: the right headers and an http.Flusher.
func (h *Handler) MetricsSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
h.sendMetricsEvent(w, flusher)
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
h.sendMetricsEvent(w, flusher)
}
}
}
The handler sends an initial snapshot immediately, then ticks every second. When the client disconnects — closes the tab, navigates away, loses network — Go cancels the request context and the goroutine exits. No cleanup code, no connection tracking. The garbage collector handles the rest.
Flushing, Timeouts, and Client Disconnects
The http.Flusher interface is the piece that makes SSE work in Go. Without it,
the net/http response writer buffers output and the client sees nothing until
the handler returns. Calling flusher.Flush() after each event pushes the bytes
to the client immediately.
The actual event formatting has one gotcha. The SSE spec says each event is a
block of data: lines followed by a blank line. If you're sending a single
JSON string, that's one line. But I'm sending rendered HTML — a template partial
that spans many lines. Raw newlines inside a data: field break the SSE parser.
Each line needs its own data: prefix:
func (h *Handler) sendMetricsEvent(w http.ResponseWriter, flusher http.Flusher) {
snapshot := h.metrics.SnapShot()
var buf bytes.Buffer
if err := h.partials.ExecuteTemplate(&buf, "metrics-cards", snapshot); err != nil {
fmt.Fprintf(w, "data: template error\n\n")
flusher.Flush()
return
}
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
for _, line := range lines {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprintf(w, "\n")
flusher.Flush()
}
The r.Context().Done() check in the select loop handles disconnects. When the
client goes away, Go's HTTP server cancels the context and the handler returns
cleanly. No leaked goroutines, no orphaned tickers. I originally had this on a
2-second interval, but once the snapshot was cached, there was no reason not to
drop it to 1 second — every viewer is just reading a pointer.
HTMX on the Receiving End
This is where it gets boring in the best way. HTMX has a built-in SSE extension. The client-side code is just HTML attributes:
<div hx-ext="sse" sse-connect="/metrics/stream" sse-swap="message" hx-swap="none">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="cyber-card p-5 rounded-lg">
<div class="text-xs font-mono text-text-muted">Uptime</div>
<div id="metric-uptime" class="text-xl font-bold font-mono">—</div>
</div>
<!-- more cards... -->
</div>
</div>
The sse-connect attribute opens the EventSource. sse-swap="message" listens
for unnamed events (the default SSE event type). hx-swap="none" tells HTMX
not to replace the container — because the actual swapping happens via OOB
(out-of-band) swap targets in the server response.
The server sends a partial template where every element has hx-swap-oob="true"
and a matching id:
{{define "metrics-cards"}}
<div id="metric-uptime" hx-swap-oob="true" class="...">{{.FormatUptime}}</div>
<div id="metric-requests" hx-swap-oob="true" class="...">{{.TotalRequests}}</div>
<div id="metric-goroutines" hx-swap-oob="true" class="...">{{.GoRoutineCount}}</div>
<div id="metric-heap" hx-swap-oob="true" class="...">{{formatBytes .MemoryAlloc}}</div>
<!-- ... every metric card -->
{{end}}
When HTMX receives the SSE event, it parses the HTML and swaps each OOB element into the matching ID on the page. Every card updates independently, in one event, with zero JavaScript. The connection auto-reconnects if it drops. The entire client-side implementation is HTML attributes and a Go template.
Scaling: What Happens at 100 Concurrent Viewers
Each SSE viewer holds one goroutine and one ticker. In Go, goroutines are cheap
— a few KB of stack each. 100 viewers means 100 goroutines, 100 tickers, and
100 atomic pointer loads per second. The ReadMemStats call still only happens
once.
The real scaling constraint is the network, not the server. Each event pushes roughly 2-3 KB of rendered HTML. At 100 viewers ticking every second, that's maybe 300 KB/s of outbound traffic. For a personal site, that's never going to be the bottleneck.
There's also a subtlety in the middleware. SSE connections are long-lived — a viewer on the metrics page holds an open connection for as long as the tab is open. The request-counting middleware needs to know not to count these as normal requests, or the metrics pollute themselves:
func CountRequests(collector *metrics.Collector) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
collector.ConnOpen()
defer collector.ConnClose()
if req.URL.Path == "/metrics/stream" || req.URL.Path == "/status/stream" {
next.ServeHTTP(w, req)
return
}
start := time.Now()
collector.IncrementRequests()
next.ServeHTTP(w, req)
collector.RecordDuration(time.Since(start))
})
}
}
SSE connections still count toward active connections (useful to see on the dashboard), but they skip request counting and duration recording. Without this, every tick would look like a new request, and the response time stats would be meaningless.
Alternatives: WebSockets vs SSE vs Polling
WebSockets give you bidirectional communication. This dashboard doesn't need it — the client never sends data to the server. SSE is simpler: it's just HTTP, works through proxies and load balancers without special configuration, and the browser handles reconnection automatically. There's no upgrade handshake, no ping/pong framing, no connection state machine.
Polling is the other obvious option. Hit /api/metrics every second, get JSON
back, update the DOM with JavaScript. It works, but every poll is a full HTTP
request-response cycle — new TCP connection (or at least a new request on a
keep-alive connection), full headers both ways. SSE opens one connection and
keeps it. For a dashboard that updates every second, the difference in overhead
is real.
The tradeoff: SSE is limited to text (no binary), it's unidirectional, and you get one connection per browser tab per domain (browsers cap EventSource connections at around 6 per origin over HTTP/1.1 — HTTP/2 multiplexes them). None of these matter for this use case. If I needed the client to send data back — say, to let viewers filter which metrics they see — WebSockets would be the right call. For a read-only dashboard, SSE is the right tool.