rgoussu@goussu: ~/library/go/protocols
~/library/go/protocols cat websockets.md

WebSockets & SSE in Go

# gorilla/websocket and coder/websocket for bidirectional push, SSE from the stdlib, goroutine-per-connection economics and backpressure.

Conceptsaved 2026-08-09 #go#protocols#websockets#sse#concurrency

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/websocket lacks 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.Context on every operation, wsjson helpers, 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, write data: lines, assert http.Flusher and 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