Day 59 — WebSockets or SSE

Updated

July 30, 2026

Day 59 — WebSockets or SSE

Stage VI · ~3h
Goal: Add one realtime channel—Server-Sent Events (SSE) or WebSockets—with clear lifecycle, client test/script, and documentation of the trade-off.

Important

Pick one. Implementing both shallowly is worse than one solid path.

Why this day exists

Request/response REST is not enough for:

  • Live dashboards
  • Notifications
  • Collaborative presence

Two stdlib-friendly options dominate simple Go services:

SSE WebSocket
Direction Server → client (HTTP) Full duplex
Protocol HTTP response stream HTTP Upgrade → WS
Reconnect Easy (EventSource) Manual or lib
Proxies Usually friendly Need Upgrade support
Binary Awkward (text events) Natural
Stdlib net/http only Library preferred

Theory 1 — SSE fundamentals

Response headers:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Body frames:

event: item_created
data: {"id":"1","name":"x"}

: keep-alive comment

data: plain line

Rules:

  • Blank line ends an event
  • data: may repeat (joined with \n)
  • Comments start with :
  • Client EventSource auto-reconnects

Go handler sketch

func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.WriteHeader(http.StatusOK)
    flusher.Flush()

    ch := s.hub.Subscribe()
    defer s.hub.Unsubscribe(ch)

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

    for {
        select {
        case <-r.Context().Done():
            return
        case <-ticker.C:
            _, _ = io.WriteString(w, ": ping\n\n")
            flusher.Flush()
        case msg, ok := <-ch:
            if !ok {
                return
            }
            _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", msg.Event, msg.Data)
            flusher.Flush()
        }
    }
}

Fan-out hub

type Message struct {
    Event string
    Data  string
}

type Hub struct {
    mu   sync.Mutex
    subs map[chan Message]struct{}
}

func NewHub() *Hub {
    return &Hub{subs: make(map[chan Message]struct{})}
}

func (h *Hub) Subscribe() chan Message {
    ch := make(chan Message, 8)
    h.mu.Lock()
    h.subs[ch] = struct{}{}
    h.mu.Unlock()
    return ch
}

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

func (h *Hub) Publish(msg Message) {
    h.mu.Lock()
    defer h.mu.Unlock()
    for ch := range h.subs {
        select {
        case ch <- msg:
        default:
            // slow consumer: drop (or disconnect policy)
        }
    }
}

Publish from createItem after successful DB write.


Theory 2 — WebSocket fundamentals

Handshake: Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Key, …

Library choice (pragmatic)

Library Notes
github.com/coder/websocket Modern, good defaults
github.com/gorilla/websocket Widely known
golang.org/x/net/websocket Older; less preferred

Stdlib does not yet provide a full WS server as ergonomic as these. Using one well-known package is acceptable for this day—document it.

Patterns

  • Read loop + write loop (often separate goroutines)
  • Ping/pong for keepalive
  • Auth on connect (query token or first message)—prefer before upgrade when possible
// conceptual — coder/websocket
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
    OriginPatterns: []string{"localhost:*"},
})
if err != nil {
    return
}
defer c.Close(websocket.StatusNormalClosure, "")

ctx := r.Context()
for {
    _, data, err := c.Read(ctx)
    if err != nil {
        return
    }
    _ = c.Write(ctx, websocket.MessageText, data) // echo lab
}

Theory 3 — Auth and multi-tenant streams

  • SSE: Authorization header works with some clients; browsers’ EventSource cannot set custom headers—use httpOnly cookie session or short-lived query token (careful with logs and referrer leakage).
  • WS: same issues; cookies sent on same-site connects.

Filter events by userID from auth context so users don’t see others’ data.

// publish user-scoped
s.hub.Publish(Message{
    Event: "item_created",
    Data:  string(payload),
})
// subscriber filters or hub stores per-user channels

Theory 4 — Backpressure & disconnects

  • Detect r.Context().Done()
  • Bound subscription channel buffers
  • Drop or disconnect slow clients
  • Do not let Publish block on one bad consumer
  • Cap max subscribers per process for lab safety

http.Server write deadlines can interfere with long-lived streams—tune WriteTimeout to 0 for the stream server or use a separate server/listener pattern in real systems. Document what you did.


Theory 5 — Testing strategy

Layer Approach
Hub Unit tests: subscribe, publish, unsubscribe, drop policy
SSE handler httptest.NewServer + short context; read until first event
WS Small Go client against NewServer
func TestHubFanout(t *testing.T) {
    h := NewHub()
    a, b := h.Subscribe(), h.Subscribe()
    h.Publish(Message{Event: "x", Data: "1"})
    select {
    case m := <-a:
        if m.Data != "1" {
            t.Fatal(m)
        }
    case <-time.After(time.Second):
        t.Fatal("timeout a")
    }
    <-b
    h.Unsubscribe(a)
    h.Unsubscribe(b)
}

Worked example — choose SSE for lab default

SSE is pure stdlib and enough for “item created” feeds. Prefer SSE unless you need client→server messages over the same socket (chat, collaborative cursors).

README declaration:

realtime: sse

Lab 1 — Setup

mkdir -p ~/lab/90daysofx/01-go/day59
cd ~/lab/90daysofx/01-go/day59
go mod init example.com/day59

Declare track in README: realtime: sse or realtime: websocket.


Lab 2A — SSE path

  1. Hub with Subscribe/Unsubscribe/Publish
  2. GET /v1/events SSE handler
  3. Publish on item create
  4. Manual test:
curl -N -H 'Accept: text/event-stream' http://127.0.0.1:8080/v1/events
# other terminal: create item
  1. Auto test: unit-test Hub thoroughly + thin handler test with cancelled context after one event.

Lab 2B — WebSocket path

  1. Accept connection
  2. Broadcast item JSON messages
  3. Script or small Go client reading one message
  4. Origin check configured for lab

Lab 3 — Auth gate

Only authenticated users subscribe. Unauthorized → 401 before stream starts (do not flush event-stream headers first).


Lab 4 — Load lite

100 subscribers + 10 publishes; ensure no deadlock (-race test with fake hub consumers). Assert Publish returns quickly even with full buffers (drop policy).


Common gotchas

Gotcha Fix
Forgetting Flush Call after each event
Buffering proxy X-Accel-Buffering: no (nginx) awareness
Blocking Publish default drop policy
Browser EventSource + bearer header Cookies or ticket
WS without ping Idle timeouts kill conns
Both protocols half-done Pick one
WriteTimeout killing streams Configure for long-lived connections
Leaking subscriptions defer Unsubscribe

Checkpoint

  • One realtime protocol working end-to-end
  • Hub or equivalent fan-out
  • Disconnect cleans subscription
  • Auth considered
  • Trade-off documented
  • Demo script in README

Commit

git add .
git commit -m "day59: realtime SSE or WebSocket live updates"

Write three personal gotchas before continuing.


Tomorrow

Day 60 — TLS awareness: local HTTPS, tls.Config minimum versions, and terminate-at-proxy architecture notes.