SSE & Realtime
Use Server-Sent Events as the default realtime primitive in GO(A)HT.
Use Server-Sent Events as the default realtime primitive. Most GO(A)HT apps only need server-to-browser push, and SSE works cleanly with both HTML-first HTMX flows and Alpine islands.
SSE serves two different clients:
- HTMX clients that hear an event and refresh HTML
- Alpine clients that hear an event and merge state
Why SSE First
Prefer SSE before WebSockets when:
- the server is the main source of updates
- clients mostly need notification, not bidirectional chat
- you want native browser support and simple reconnect behavior
- the UI already speaks in HTML fragments or small JSON islands
For BagTrax-style apps, that covers most realtime needs.
The SSE Surface
SSE is one of the core response surfaces:
- page HTML
- partial HTML
- JSON for Alpine islands
- SSE
The SSE route usually lives beside the page route for the same resource:
mux.HandleFunc("GET /games/{id}", h.Pages.ShowGame)
mux.HandleFunc("GET /games/{id}/scoreboard", h.Partials.Scoreboard)
mux.HandleFunc("GET /api/games/{id}/state", h.API.GameState)
mux.HandleFunc("GET /games/{id}/events", h.SSE.StreamGame)
Server Handler
func (h *SSEHandlers) StreamGame(w http.ResponseWriter, r *http.Request) {
gameID := r.PathValue("id")
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(gameID)
defer cleanup()
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
for {
select {
case <-r.Context().Done():
return
case msg := <-ch:
_, _ = w.Write([]byte(msg))
_, _ = w.Write([]byte("\n\n"))
flusher.Flush()
}
}
}
The hub implementation can stay local for one instance or publish through Redis when you need multi-instance fanout.
Mode 1: HTMX Refresh Events
HTMX uses SSE as a signal, not as the UI payload. The event tells the browser to fetch fresh HTML.
This mode requires the HTMX SSE extension script in the page layout, not just core HTMX.
<section
id="scoreboard"
hx-ext="sse"
sse-connect="/games/{{ .Game.ID }}/events"
hx-get="/games/{{ .Game.ID }}/scoreboard"
hx-trigger="sse:refresh-scoreboard"
hx-swap="outerHTML"
>
{{ template "partials/scoreboard.html" .Scoreboard }}
</section>
Server event:
event: refresh-scoreboard
data: changed
This is the default HTML-first mode. The stream tells HTMX when to refresh; the fragment route returns the updated UI.
Mode 2: Alpine State Events
Alpine uses SSE differently. The event payload carries state that the island merges locally.
<script>
function gameStore(gameID) {
return {
state: { home: { score: 0 }, away: { score: 0 } },
loading: false,
connectEvents() {
const es = new EventSource(`/games/${gameID}/events`)
es.addEventListener('state', (e) => {
if (!this.loading) this.state = JSON.parse(e.data)
})
},
}
}
</script>
Server event:
event: state
data: {"home":{"score":12},"away":{"score":9}}
Use this mode only for islands that truly need client-side state.
Broadcasting
After a successful write, publish the smallest event that fits the client:
func (h *APIHandlers) RecordThrow(w http.ResponseWriter, r *http.Request) {
nextState, err := h.games.RecordThrow(r.Context(), r.PathValue("id"), r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
h.hub.Publish(r.PathValue("id"), "event: refresh-scoreboard\ndata: changed")
payload, _ := json.Marshal(nextState)
h.hub.Publish(r.PathValue("id"), "event: state\ndata: "+string(payload))
writeJSON(w, nextState)
}
Both event types can coexist on the same stream. HTMX listens for refresh events. Alpine listens for state events.
Choosing The Event Style
- Use refresh events when the UI is server-rendered.
- Use state events when an Alpine island owns the interaction.
- Prefer refresh events by default because they preserve the HTML-first model.
Gotchas
- Flush after every event write.
- Always clean up subscriptions on disconnect.
- Buffer slow clients so one browser does not stall the hub.
- Keep event names specific to the surface being refreshed.
- Do not send large JSON payloads to HTMX clients that only need a refresh signal.