Day 59 — WebSockets or SSE
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.
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
EventSourceauto-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:
Authorizationheader works with some clients; browsers’EventSourcecannot 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 channelsTheory 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/day59Declare track in README: realtime: sse or realtime: websocket.
Lab 2A — SSE path
- Hub with Subscribe/Unsubscribe/Publish
GET /v1/eventsSSE handler
- Publish on item create
- Manual test:
curl -N -H 'Accept: text/event-stream' http://127.0.0.1:8080/v1/events
# other terminal: create item- Auto test: unit-test Hub thoroughly + thin handler test with cancelled context after one event.
Lab 2B — WebSocket path
- Accept connection
- Broadcast item JSON messages
- Script or small Go client reading one message
- 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.