StackPractices
intermediate By Mathias Paulenko

Implement Server-Sent Events in Go for Real-Time Updates

Build a production-ready Server-Sent Events endpoint in Go with connection management, heartbeat pings, and graceful client disconnect handling.

Overview

Server-Sent Events give you a lightweight, one-way channel for pushing real-time updates from server to client over plain HTTP. Unlike WebSockets, SSE doesn’t need a protocol upgrade. It reuses standard HTTP connections, and the browser’s built-in EventSource API handles reconnection automatically.

I once spent two days debugging an SSE endpoint that worked perfectly in development but dropped events in production. The culprit? An nginx proxy buffering the response. The fix was a single line — proxy_buffering off; — but finding it taught me that SSE’s simplicity hides a few infrastructure gotchas. This recipe walks through the gotchas I’ve bled on — the kind that ate a full afternoon and left me with a grumpy on-call shift.

What you’ll get: a production-ready SSE handler in Go with hub-based connection management, heartbeat pings, graceful disconnect handling, client-side EventSource code, Redis broadcast for horizontal scaling, authentication, and testing with httptest.

When to Use

Reach for SSE when the server needs to push notifications, logs, or live metrics to browsers and the clients only receive. It shines when you want to lean on existing HTTP infrastructure — load balancers, CDNs, auth middleware — instead of bolting on a WebSocket layer for one-way traffic. See also Debounce and Throttle.

When NOT to Use

Don’t reach for SSE when clients need to send messages back to the server in real time — WebSockets are the right tool for that. Skip it for binary or very high-frequency data too; WebSockets or WebTransport fit better there. And if you can’t control proxy or load-balancer timeouts that may close idle connections, SSE can be a pain to keep alive.

Solution

sequenceDiagram diagram: participant C as Client (EventSource)

Basic SSE handler

// handlers/sse.go
package handlers

import (
    "fmt"
    "net/http"
    "time"
)

type Event struct {
    ID    string
    Type  string
    Data  string
    Retry int
}

func (e Event) String() string {
    var result string
    if e.ID != "" {
        result += fmt.Sprintf("id: %s\n", e.ID)
    }
    if e.Type != "" {
        result += fmt.Sprintf("event: %s\n", e.Type)
    }
    if e.Retry > 0 {
        result += fmt.Sprintf("retry: %d\n", e.Retry)
    }
    result += fmt.Sprintf("data: %s\n\n", e.Data)
    return result
}

func SSEHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }

    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    clientGone := r.Context().Done()

    for {
        select {
        case <-clientGone:
            return
        case <-ticker.C:
            now := time.Now().Unix()
            event := Event{
                ID:   fmt.Sprintf("%d", now),
                Type: "ping",
                Data: fmt.Sprintf(`{"timestamp": %d}`, now),
            }
            fmt.Fprint(w, event.String())
            flusher.Flush()
        }
    }
}

Hub-based connection management

// sse/hub.go
package sse

import "sync"

type Hub struct {
    clients map[chan Event]bool
    mu      sync.RWMutex
}

func NewHub() *Hub {
    return &Hub{clients: make(map[chan Event]bool)}
}

func (h *Hub) Subscribe() chan Event {
    ch := make(chan Event, 10)
    h.mu.Lock()
    h.clients[ch] = true
    h.mu.Unlock()
    return ch
}

func (h *Hub) Unsubscribe(ch chan Event) {
    h.mu.Lock()
    delete(h.clients, ch)
    h.mu.Unlock()
    close(ch)
}

func (h *Hub) Broadcast(event Event) {
    h.mu.RLock()
    defer h.mu.RUnlock()

    for ch := range h.clients {
        select {
        case ch <- event:
        default:
            // Channel full, drop event for this client
        }
    }
}

Production handler with heartbeat

// handlers/events.go
func EventStream(hub *sse.Hub) 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("Connection", "keep-alive")

        flusher, ok := w.(http.Flusher)
        if !ok {
            http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
            return
        }

        client := hub.Subscribe()
        defer hub.Unsubscribe(client)

        heartbeat := time.NewTicker(30 * time.Second)
        defer heartbeat.Stop()

        clientGone := r.Context().Done()

        // Send initial connection event
        fmt.Fprintf(w, "event: connected\ndata: %s\n\n", `{"status": "ok"}`)
        flusher.Flush()

        for {
            select {
            case <-clientGone:
                return
            case event := <-client:
                fmt.Fprint(w, event.String())
                flusher.Flush()
            case <-heartbeat.C:
                fmt.Fprint(w, ": heartbeat\n\n")
                flusher.Flush()
            }
        }
    }
}

Client-side EventSource

// client.js
const evtSource = new EventSource('/api/events');

evtSource.addEventListener('connected', (e) => {
  console.log('Connected:', JSON.parse(e.data));
});

evtSource.addEventListener('price-update', (e) => {
  const update = JSON.parse(e.data);
  document.getElementById('price').textContent = update.price;
});

evtSource.onerror = (err) => {
  console.error('SSE error:', err);
  // Browser auto-reconnects with exponential backoff
};

window.addEventListener('beforeunload', () => {
  evtSource.close();
});

Explanation

So what’s actually happening on the wire? Your handler sets Content-Type: text/event-stream and starts writing plain text events — no framing, no binary, just lines of text. Each event can carry four fields (data, event, id, retry), but honestly most events only use data. The browser remembers the last id it saw and sends it back in the Last-Event-ID header when it reconnects, which lets your server replay anything the client missed. Lines beginning with : are heartbeat comments — proxies see traffic and keep the socket open, but the browser ignores them. When the client closes the tab, r.Context().Done() fires, your handler returns, and the deferred Unsubscribe cleans up the channel. Skip that cleanup and you’ve got a goroutine leak that’ll bite you at 3am.

Variants

Broadcast with Redis for multiple Go instances

For horizontal scaling, publish events to Redis Pub/Sub and have each Go process subscribe, then fan them out to its local SSE clients. See Real-Time Notifications for Redis pub/sub patterns.

Authenticate SSE connections

The catch is that browsers can’t set custom headers through EventSource. Instead, pass a short-lived token as a query parameter and validate it before subscribing:

token := r.URL.Query().Get("token")
if !validateToken(token) {
    http.Error(w, "Unauthorized", http.StatusUnauthorized)
    return
}

For cross-origin SSE, set CORS headers explicitly on the endpoint.

Test with httptest

func TestSSEHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/events", nil)
    rec := httptest.NewRecorder()

    SSEHandler(rec, req)

    res := rec.Result()
    if res.Header.Get("Content-Type") != "text/event-stream" {
        t.Fatalf("expected text/event-stream, got %s", res.Header.Get("Content-Type"))
    }
}

Best Practices

  • Always call Flusher.Flush() after each event, or the client will see nothing until the buffer fills.
  • Run SSE behind HTTP/2-capable load balancers so many streams can share a single connection.
  • Set Cache-Control: no-cache and Connection: keep-alive, or proxies will buffer your stream and events will arrive in bursts.
  • Send a heartbeat comment every 25–30 seconds to keep connections alive through corporate proxies.
  • Limit connections per client IP — or require auth — so a single bad actor can’t exhaust your file descriptors.
  • Bump your write timeouts well above the REST defaults — a 30-second timeout will kill a long-lived SSE stream that’s just sending heartbeats.

Common Mistakes

  • Forgetting to call Flush(). Without flushing, the event sits buffered and the client sees nothing.
  • Ignoring client disconnect. A missing r.Context().Done() check leaves goroutines and channels running forever.
  • Missing Cache-Control: no-cache. Proxies buffer the response, so events arrive in bursts or not at all.
  • Sending events without IDs. Without id: fields, the browser can’t replay missed events after reconnecting.
  • Running the same handler without state isolation. Each Go process gets its own hub — don’t assume a single process, or clients on different backends will miss events. Use Redis fan-out.

See Also

  • MDN: SSE docs — official browser docs for the EventSource API, including reconnection behavior and the Last-Event-ID header.
  • HTML spec: Server-Sent Events — the authoritative spec for the text/event-stream format, field names, and parsing rules.
  • Go net/http packagehttp.Flusher, http.ResponseWriter, r.Context() reference.
  • Redis Pub/Sub — Redis Pub/Sub docs for the broadcast variant.

Frequently Asked Questions

How does SSE compare to WebSockets?

SSE is simpler for server-to-client push — use WebSockets when you need bi-directional communication or binary data.

Can SSE work through corporate proxies?

Yes, but some proxies have short timeouts. Send heartbeat comments every 30 seconds to keep connections open.

What is the maximum number of concurrent SSE connections?

Over HTTP/1.1, browsers allow about 6 connections per domain. HTTP/2 removes this limit.

How do I handle client reconnection with Last-Event-ID?

Read the Last-Event-ID header and replay any events with higher IDs. Assign sequential IDs with the id: field and store recent events in a small in-memory ring buffer.

How do I broadcast SSE to multiple clients in Go?

Maintain a map of subscribed channels. Use a non-blocking send with a default case in a select so slow clients don't block the broadcast. For fan-out across instances, add Redis Pub/Sub.

How do I handle SSE behind a load balancer?

Use long timeouts, disable response buffering in nginx with proxy_buffering off;, and use Redis Pub/Sub to share events across instances if clients may land on different backends.

Why does my SSE stream work locally but break in production?

It's almost always a proxy or load balancer buffering the response. Nginx, Cloudflare, and some CDNs buffer by default. Set proxy_buffering off; in nginx, Cache-Control: no-cache in your response headers, and send heartbeats every 25-30 seconds to keep the connection alive through corporate proxies with short idle timeouts.

What's the difference between SSE and long polling?

Long polling makes a new HTTP request after each event (or timeout). SSE keeps a single connection open and streams dozens or hundreds of events over it. SSE is more efficient for high-frequency updates because it skips the overhead of repeated HTTP handshakes. Long polling works everywhere but wastes bandwidth and adds latency.