SSE & Realtime

Server-Sent Events with Redis pub/sub for real-time updates.

The GOaT stack uses Server-Sent Events (SSE) for real-time updates. Redis pub/sub enables multi-instance broadcasting. No WebSocket library needed.

The SSE Hub

A central hub manages client connections and broadcasts events:

type SSEHub struct {
    mu      sync.RWMutex
    clients map[int32]map[chan string]struct{} // gameID → client channels
    rdb     *redis.Client
    ctx     context.Context
}

func NewSSEHub(redisURL string) *SSEHub {
    hub := &SSEHub{
        clients: make(map[int32]map[chan string]struct{}),
    }
    if redisURL != "" {
        opt, _ := redis.ParseURL(redisURL)
        hub.rdb = redis.NewClient(opt)
        go hub.subscribeRedis() // Listen for cross-instance messages
    }
    return hub
}

Subscribe / Broadcast

// Subscribe returns a channel and cleanup function
func (h *SSEHub) Subscribe(gameID int32) (chan string, func()) {
    ch := make(chan string, 10)
    h.mu.Lock()
    if h.clients[gameID] == nil {
        h.clients[gameID] = make(map[chan string]struct{})
    }
    h.clients[gameID][ch] = struct{}{}
    h.mu.Unlock()

    cleanup := func() {
        h.mu.Lock()
        delete(h.clients[gameID], ch)
        h.mu.Unlock()
        close(ch)
    }
    return ch, cleanup
}

// Broadcast publishes to Redis (or local if no Redis)
func (h *SSEHub) Broadcast(gameID int32, event string) {
    if h.rdb != nil {
        h.rdb.Publish(h.ctx, fmt.Sprintf("game:%d:events", gameID), event)
    } else {
        h.broadcastLocal(gameID, event)
    }
}

SSE HTTP Handler

func (h *SSEHandler) GameStream(w http.ResponseWriter, r *http.Request) {
    gameID, _ := strconv.ParseInt(r.PathValue("id"), 10, 32)

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    ch, cleanup := h.hub.Subscribe(int32(gameID))
    defer cleanup()

    flusher, _ := w.(http.Flusher)

    for {
        select {
        case <-r.Context().Done():
            return
        case event := <-ch:
            fmt.Fprintf(w, "%s\n\n", event)
            flusher.Flush()
        }
    }
}

Broadcasting from Handlers

After any state change, broadcast to all connected clients:

func (h *GameHandler) APIRecordThrow(w http.ResponseWriter, r *http.Request) {
    // ... process throw ...

    // Broadcast updated state as JSON via SSE
    go func() {
        jsonBytes, _ := json.Marshal(stateToJSON(&state))
        h.sseHub.Broadcast(gameID, "event: state\ndata: "+string(jsonBytes))
    }()

    writeJSON(w, stateToJSON(&state))
}

Client-Side: HTMX

For server-rendered pages, use HTMX’s SSE extension:

<div
  hx-ext="sse"
  sse-connect="/game/{{ .ID }}/events"
  hx-get="/game/{{ .ID }}"
  hx-trigger="sse:refresh"
  hx-target="main"
  hx-swap="innerHTML"
>
  <!-- Re-fetches and swaps the full page content on "refresh" events -->
</div>

Client-Side: Alpine.js

For reactive components, use EventSource directly:

connectSSE() {
    const es = new EventSource('/game/' + this.gameId + '/events');

    es.addEventListener('state', (e) => {
        const data = JSON.parse(e.data);
        if (!this.loading) this.mergeState(data);
    });

    es.addEventListener('refresh', () => this.fetchState());

    es.onerror = () => {
        setTimeout(() => this.connectSSE(), 2000); // Reconnect
    };
}

Redis Pub/Sub for Multi-Instance

When running multiple server instances, Redis ensures all clients see events:

func (h *SSEHub) subscribeRedis() {
    pubsub := h.rdb.PSubscribe(h.ctx, "game:*:events")
    ch := pubsub.Channel()

    for msg := range ch {
        var gameID int32
        fmt.Sscanf(msg.Channel, "game:%d:events", &gameID)
        h.broadcastLocal(gameID, msg.Payload) // Forward to local clients
    }
}

Event Format

SSE events follow a simple format:

event: state
data: {"game_id":1,"team1":{"score":12},...}

event: refresh
data: player-joined
  • state events carry the full game state as JSON (for Alpine.js).
  • refresh events tell HTMX to re-fetch the page.

Gotchas

  • Buffered channels. Use make(chan string, 10) to avoid blocking the broadcaster if a client is slow.
  • Clean up on disconnect. Always defer the cleanup function. Leaked channels = memory leak.
  • Flush after every write. SSE requires flushing the HTTP response.
  • Reconnection is built into EventSource. Browsers auto-reconnect, but add a manual reconnect for error cases.
  • No Redis? It still works. The hub falls back to local-only broadcasting. Add Redis when you scale to multiple instances.