Overview
Go has no supported stdlib WebSocket implementation — golang.org/x/net/websocket is
frozen and explicitly points elsewhere — so the workhorse is gorilla/websocket, with
coder/websocket as the modern context-aware alternative. SSE, by contrast, needs no
library at all: it is plain HTTP with a flush. Where Go genuinely shines is the
connection economics — a goroutine per connection costs kilobytes, so the
thread-pool-and-event-loop contortions the JVM historically needed for 100k connections
simply don't arise.
Key points
- No stdlib WebSocket:
x/net/websocketlacks RFC 6455 completeness and is deprecated in practice; don't start there. - gorilla/websocket: the de-facto library — un-archived after the 2022 scare, actively maintained again. Low-level by design: you write the read/write pump goroutines, ping/pong keepalive and close handling yourself (its chat example is the canonical template).
- Concurrency rule: gorilla connections allow one concurrent writer and one concurrent reader — all writes must funnel through a single goroutine, typically draining a per-connection buffered channel (the "write pump").
- coder/websocket (formerly nhooyr/websocket): smaller API, first-class
context.Contexton every operation,wsjsonhelpers, compiles to WASM for client-side Go; the usual pick for new code that wants idiomatic cancellation. - melody: a thin session/rooms/broadcast layer over gorilla — connection registry, broadcast to all/filtered sessions — when the app is chat-shaped and you don't want to hand-roll a hub.
- SSE is trivially stdlib: set
Content-Type: text/event-stream, writedata:lines, asserthttp.Flusherand flush per event. One-directional server push (feeds, progress, LLM token streams) rarely needs more; auto-reconnect comes free from the browser. - Fan-out economics: 100k conns ≈ 100k goroutines at a few KB stack each — commodity memory. The design problem shifts from "how do I not block a thread" (the JVM story) to "how do I bound per-connection buffers".
- Backpressure: a slow client must never stall the hub — send on the connection's
channel with a non-blocking
select/default, drop or disconnect laggards, and set write deadlines so a dead peer can't wedge the write pump.
Details
The hub pattern
The canonical shape for broadcast: one hub goroutine owning a map[*Client]bool, with
register/unregister/broadcast channels; each client runs a read pump (drives
ping/pong and reads messages into the hub) and a write pump (drains client.send, fires
periodic pings). All state mutation happens in the hub goroutine, so there are no locks
around the connection map. Scaling beyond one process means moving fan-out into a broker —
Redis pub/sub or NATS — with each instance subscribing and pushing to its local
connections; see the messaging note.
SSE vs WebSocket
| Concern | SSE | WebSocket |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Transport | Plain HTTP (h2-friendly) | Upgraded TCP connection |
| Reconnect | Built into EventSource (Last-Event-ID) |
Hand-rolled |
| Proxies/infra | Just works | Occasionally mangled |
| Library needed | None | gorilla or coder |
Reach for SSE first; upgrade to WebSocket only when the client genuinely sends mid-stream.
Slow-client policy
Decide explicitly what happens when a client can't keep up: unbounded buffering (memory
leak), blocking (one slow client stalls broadcast), dropping messages (fine for tickers),
or closing the connection (fine when the client can resync on reconnect). The
per-connection buffered channel makes the policy one select statement.
Examples
// SSE from the stdlib.
func events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
f := w.(http.Flusher)
for {
select {
case ev := <-feed:
fmt.Fprintf(w, "data: %s\n\n", ev)
f.Flush()
case <-r.Context().Done():
return
}
}
}
// Non-blocking hub broadcast: drop the laggard, don't stall the hub.
for c := range h.clients {
select {
case c.send <- msg:
default:
close(c.send)
delete(h.clients, c)
}
}
Related
- Backend protocols in Go — the map — parent map of the options.
- WebSockets and bidirectional protocols — the protocol-level design concerns.
- Messaging in Go — Kafka, NATS & AMQP — the broker layer behind multi-instance fan-out.
- Protohackers ladder — hands-on practice with exactly these connection-handling patterns.
- WebSockets & SSE in Java — the JVM contrast: container-managed endpoints and the thread-per-connection cost Go's goroutines dissolve.