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:

  1. HTMX clients that hear an event and refresh HTML
  2. 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.