Chapter 12: WebSockets and Real-Time Bidirectional Communication

Updated

October 31, 2026

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/websocket

The 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

  1. Server Sends Ping: Every 54 seconds, the server sends an opcode 0x9 (Ping) frame.
  2. 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.
  3. Dead Connection Detection: If the server does not receive a Pong within the pongWait window (60 seconds), the read deadline trips, unblocking ReadMessage() 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

  1. Every server instance runs its own local Hub managing local client connections.
  2. When a local client publishes a message, the server publishes it to a shared Redis channel.
  3. Every server instance subscribes to the Redis channel; upon receiving an event, it relays the message to all locally connected clients.
  4. Load Balancer Configuration: Configure your reverse proxy (Nginx, Envoy, or AWS ALB) to support WebSocket upgrades (Upgrade and Connection headers) 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.