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
stateevents carry the full game state as JSON (for Alpine.js).refreshevents 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.