Real-Time Dashboards with SSE in Go

go sse performance htmx
RSS Feed

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.