Chapter 12: WebSockets and Real-Time Bidirectional Communication
Chapter 12: WebSockets and Real-Time Bidirectional Communication
Standard HTTP follows a request-and-reply model initiated by the client. For real-time applications—such as financial tickers, collaborative editing, chat systems, and live dashboards—polling is inefficient and introduces unacceptable latency.
The WebSocket Protocol (RFC 6455) provides full-duplex, bidirectional communication channels over a single persistent TCP connection. It begins as a standard HTTP/1.1 request and performs an Upgrade Handshake, transforming the connection into a lightweight framing layer that passes messages directly between browser and server.
In this chapter, you will learn the low-level mechanics of the WebSocket protocol, implement robust WebSocket handlers using production libraries, build a scalable real-time hub, manage ping/pong heartbeats, and scale WebSocket infrastructure across a cluster using a message backplane.
How WebSockets Work (Handshake, Frames, Upgrade)
The Upgrade Handshake
A WebSocket connection begins with an ordinary HTTP GET request:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server agrees to switch protocols, it responds with status code 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The server calculates Sec-WebSocket-Accept by concatenating the client’s Sec-WebSocket-Key with a well-known GUID string (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), taking the SHA-1 hash, and Base64-encoding the result.
Once this handshake completes, the underlying TCP connection stops speaking HTTP and transitions immediately to WebSocket Framing.
WebSocket Framing Format
Unlike raw TCP streams, WebSockets provide message-oriented framing:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Masking-key (4 bytes, if MASK bit set by client) |
+---------------------------------------------------------------+
| Payload Data |
+---------------------------------------------------------------+
- FIN Bit: Indicates if this frame is the final fragment of a message.
- Opcodes:
0x1(Text/UTF-8),0x2(Binary data),0x8(Close connection),0x9(Ping),0xA(Pong). - Client-to-Server Masking: All frames sent from browser clients to the server must be XOR-masked with a random 4-byte key to prevent cache-poisoning attacks on intermediary proxies.
Using gorilla/websocket in Production
The most widely adopted, battle-tested WebSocket library in the Go ecosystem is github.com/gorilla/websocket.
To install it:
go get github.com/gorilla/websocketThe Upgrader
The websocket.Upgrader takes an inbound HTTP request and upgrades its underlying TCP socket:
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// CheckOrigin allows configuring CORS policies
CheckOrigin: func(r *http.Request) bool {
// In production, validate against allowed origins
return true
},
}Building a Real-Time Chat Server with WebSockets
A production WebSocket server follows the Hub and Client architecture. The Hub owns the registration state and broadcasts messages to all connected client channels, avoiding locks across connections.
┌──────────────┐
│ Hub Loop │
└──────┬───────┘
┌─────────────────────┼─────────────────────┐
│ Broadcast │ Register │ Unregister
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Client #1 │ │ Client #2 │ │ Client #N │
│ (Read/Write Gs) │ │ (Read/Write Gs) │ │ (Read/Write Gs) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
1. The Hub
// hub.go
package main
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
// Slow client buffer full: disconnect to protect server
close(client.send)
delete(h.clients, client)
}
}
}
}
}2. The Client: Read and Write Loops with Heartbeats
// client.go
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("unexpected close error: %v", err)
}
break
}
c.hub.broadcast <- message
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
_, _ = w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
go client.writePump()
go client.readPump()
}Handling Connection Heartbeats and Reconnection
WebSockets run over long-lived TCP sessions. When a user closes a laptop lid or loses mobile signal, intermediate NAT routers drop the connection entry from their translation tables without informing either endpoint.
The Ping/Pong Protocol
- Server Sends Ping: Every 54 seconds, the server sends an opcode
0x9(Ping) frame. - Client Responds with Pong: RFC 6455 requires compliant WebSocket clients (including web browsers) to automatically respond with an opcode
0xA(Pong) frame carrying identical application data. - Dead Connection Detection: If the server does not receive a Pong within the
pongWaitwindow (60 seconds), the read deadline trips, unblockingReadMessage()with an error and terminating the dead goroutines.
Scaling WebSocket Servers Across Multiple Instances
When your application expands beyond a single physical server, clients connected to Instance A cannot receive messages broadcast by clients connected to Instance B.
Client 1 ──► [ Server Instance A ] ──┐
├──► [ Redis Pub/Sub Backplane ]
Client 2 ──► [ Server Instance B ] ──┘
Implementing a Redis Pub/Sub Backplane
- Every server instance runs its own local Hub managing local client connections.
- When a local client publishes a message, the server publishes it to a shared Redis channel.
- Every server instance subscribes to the Redis channel; upon receiving an event, it relays the message to all locally connected clients.
- Load Balancer Configuration: Configure your reverse proxy (Nginx, Envoy, or AWS ALB) to support WebSocket upgrades (
UpgradeandConnectionheaders) with high proxy read timeouts.
Chapter Summary
- WebSockets upgrade standard HTTP connections into full-duplex, framed bidirectional TCP streams.
- Framing provides message boundaries, eliminating the need for custom byte-level delimiters.
- The Hub Pattern decouples connection concurrency, preventing lock contention across clients.
- Always use Ping/Pong heartbeats to detect dead connections and prevent intermediate NAT firewalls from killing idle sessions.
- Scale multi-node WebSocket clusters using an external Redis Pub/Sub backplane.
- In the next chapter, we will dive into high-performance networking and the Go runtime netpoller.