Adding WebSockets to Your API
REST is built around request-response: the client always speaks first. Some features — chat, live dashboards, multiplayer state, collaborative editing — need the server to push data the moment it happens, in both directions, over one long-lived connection. That's what WebSockets are for. This chapter uses github.com/gorilla/websocket, the de facto standard WebSocket library in the Go ecosystem, to add a real-time channel to an API. Part 2, Chapter 12 covered the WebSocket protocol itself, including a native handshake implementation; this chapter focuses on the gorilla-based API integration.
HTTP vs WebSocket
A WebSocket connection starts life as a normal HTTP request with an Upgrade: websocket header. The server accepts the upgrade, and from that point on, the TCP connection is repurposed to carry WebSocket frames instead of further HTTP requests — a single persistent, full-duplex channel that stays open until either side closes it.
Installing gorilla/websocket
go get github.com/gorilla/websocket
Upgrading a Connection
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://app.example.com"
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("upgrade failed:", err)
return
}
defer conn.Close()
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
log.Println("read error:", err)
return
}
if err := conn.WriteMessage(msgType, msg); err != nil {
log.Println("write error:", err)
return
}
}
}
This is a plain echo server: it upgrades the connection, then loops reading a message and writing it straight back. CheckOrigin matters in production — the default policy rejects cross-origin upgrade requests, and you should replace it with an explicit allow-list rather than a function that always returns true.
gorilla/websocket explicitly documents that a connection supports only one concurrent reader and one concurrent writer. If multiple goroutines might write to the same connection — common once you add broadcasting — you must serialize writes yourself, typically with a mutex or by funneling all writes through a single goroutine reading from a channel.A Broadcast Hub
Most real-time features aren't one-to-one echo; they're one-to-many broadcast — a chat room, a live scoreboard, a shared cursor position. The common pattern is a central hub that owns the set of connected clients and serializes all writes through one goroutine:
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.Mutex
}
func newHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
}
}
func (h *Hub) run() {
for {
select {
case conn := <-h.register:
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
case conn := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[conn]; ok {
delete(h.clients, conn)
conn.Close()
}
h.mu.Unlock()
case msg := <-h.broadcast:
h.mu.Lock()
for conn := range h.clients {
err := conn.WriteMessage(websocket.TextMessage, msg)
if err != nil {
conn.Close()
delete(h.clients, conn)
}
}
h.mu.Unlock()
}
}
}
func (h *Hub) serveWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
h.register <- conn
go func() {
defer func() { h.unregister <- conn }()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
h.broadcast <- msg
}
}()
}
Because run() is the only goroutine that ever writes to h.clients or calls WriteMessage on a broadcast, there's no data race and no need for per-connection write locking beyond what the hub already guarantees.
Keeping Connections Alive
Idle WebSocket connections can be silently dropped by proxies and load balancers. The standard fix is a ping/pong heartbeat:
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
The server pings every 30 seconds; the client's WebSocket implementation replies with a pong automatically, resetting the read deadline and proving the connection is still alive.
Authenticating a WebSocket Connection
The Upgrade: websocket handshake is still a normal HTTP request before the protocol switch, so it can carry the same bearer token used elsewhere in the API — either as an Authorization header, if the client library supports setting one on the upgrade request, or as a query parameter when it doesn't (browsers' native WebSocket API cannot set custom headers):
func wsHandler(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
// parseToken is the same JWT-parsing helper Chapter 6.10 covers in
// full; borrowed a couple of chapters early because this handshake
// needs it before the protocol switches away from plain HTTP.
claims, err := parseToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// claims.UserID is now available for the lifetime of this connection
}
Validating the token before calling upgrader.Upgrade matters: once the upgrade succeeds, you're committed to the WebSocket protocol on that connection and can no longer send a normal HTTP error response.
Real-World Example: A Chat Message
Client sends (as a text frame):
{"user": "ada", "text": "hello, room"}
Server broadcasts the same frame to every other connected client, unchanged — the hub in this example doesn't need to parse the message at all; it just relays bytes. A more advanced hub would decode the JSON to add a server-generated timestamp or filter by room ID before rebroadcasting.
Closing Connections Cleanly
The WebSocket protocol has a proper close handshake — don't just let the TCP connection drop:
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(
websocket.CloseNormalClosure, "server shutting down",
))
Sending a close frame lets the client distinguish a graceful shutdown from a network failure, which matters for reconnection logic on the client side.
Frequently Asked Questions
My server panics or corrupts frames once I add broadcasting — what went wrong?
Almost certainly two goroutines called WriteMessage on the same *websocket.Conn at once, which the <Warning> earlier in this chapter warns about explicitly: gorilla/websocket only supports one concurrent writer per connection. The hub pattern sidesteps this entirely by making run() the only goroutine that ever writes to any client connection, so every broadcast is naturally serialized instead of racing.
Why does the hub use channels (register, unregister, broadcast) instead of just locking the clients map directly from every goroutine?
It's the same one-writer discipline applied to the whole hub, not just individual writes — funneling every state change through run()'s select loop means you never have to reason about which goroutine is allowed to touch h.clients at any given moment. A mutex around the map would technically work for the map itself, but it wouldn't stop two goroutines from both calling WriteMessage on the same connection concurrently, which is the actual failure this design avoids.
Why validate the auth token before calling upgrader.Upgrade instead of after?
Because the upgrade is a one-way door — once Upgrade succeeds, the HTTP connection has already been repurposed to speak the WebSocket protocol, and you can no longer send back a normal http.Error with a 401 status. Checking parseToken first, while the request is still plain HTTP, is the only point where rejecting an unauthenticated client with a proper status code is even possible.
If browsers can't set custom headers on a WebSocket handshake, isn't putting the token in a query parameter insecure?
It's the accepted workaround given the constraint, but it does mean the token can end up in server access logs or browser history, so short-lived tokens and HTTPS (which upgrades to wss://) are what keep the practice reasonably safe. When the client isn't a browser and can set headers freely, an Authorization header on the upgrade request avoids that exposure entirely.
Why bother with ping/pong heartbeats if the TCP connection would eventually time out on its own anyway? Because idle connections are often the ones silently killed first — proxies and load balancers between client and server frequently drop connections that go quiet for too long, well before any OS-level TCP timeout would ever fire. The 30-second ping in this chapter keeps the connection visibly active to every hop in between, and the read-deadline reset on each pong is what lets the server actually notice when a client has gone away instead of holding a dead connection open indefinitely.
When WebSockets Are the Right Tool
WebSockets earn their complexity when you need low-latency, bidirectional, high-frequency updates — live collaboration, gaming, trading dashboards. If the server only ever needs to push updates to the client, and the client never needs to send data back over the same channel, the next chapter's topic — Server-Sent Events — is a simpler protocol that solves that narrower problem with plain HTTP.