WebSocket #

HTTP is request-response: the client asks, the server answers, the connection ends. For real-time applications like chat, live notifications, or auto-updating dashboards, this model is inefficient — the client has to keep polling the server. WebSocket solves this by turning an HTTP connection into a persistent two-way connection: after the initial handshake, the server can send data at any time without the client asking. The Go standard library doesn’t provide a complete WebSocket implementation, but gorilla/websocket is a very mature library that has become the de-facto standard in the Go ecosystem.

How WebSocket Works #

WebSocket starts with an ordinary HTTP request asking for an “upgrade”:

Client → Server:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The handshake transition process from the HTTP protocol into a persistent two-way WebSocket connection can be illustrated in the following flow diagram:

sequenceDiagram
    actor K as "Client (Browser)"
    participant S as "Go Server (http.Server)"

    Note over K,S: 1. Handshake Initiation (HTTP)
    K->>S: GET /ws (Headers: Upgrade: websocket, Connection: Upgrade)
    S-->>K: HTTP/1.1 101 Switching Protocols (Switching connection to TCP socket)
    
    Note over K,S: 2. Persistent Connection (Full-Duplex TCP)
    rect rgb(230, 245, 255)
        K->>S: Data Frame (Text / Binary Message)
        S->>K: Data Frame (Text / Binary Message)
        S->>K: Ping Frame (Heartbeat)
        K-->>S: Pong Frame (Heartbeat)
    end
    
    Note over K,S: 3. Connection Closing
    K->>S: Close Frame
    S-->>K: Close ACK

After a successful handshake (status 101), the same TCP connection is used for WebSocket communication — not HTTP anymore. Data is sent in frames that can be text or binary.


Installation #

go get github.com/gorilla/websocket

Upgrader — Turning HTTP into WebSocket #

import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    // CheckOrigin prevents Cross-Site WebSocket Hijacking
    // Returning true is fine in development, check the origin in production!
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return origin == "https://myapp.com" || origin == "http://localhost:3000"
    },
}

Separate Read and Write Loops #

This is the most important pattern in Go WebSocket. Writing to a connection is NOT thread-safe — if two goroutines write simultaneously, it panics. The solution: one dedicated write goroutine, with other goroutines sending messages via a channel:

type Client struct {
    conn   *websocket.Conn
    sendCh chan []byte  // the queue of messages to send
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("Upgrade error:", err)
        return
    }

    client := &Client{
        conn:   conn,
        sendCh: make(chan []byte, 256),
    }

    go client.writePump()  // the dedicated write goroutine
    client.readPump()      // read in this goroutine
}

// writePump — the ONLY goroutine allowed to write to conn
func (c *Client) writePump() {
    ticker := time.NewTicker(54 * time.Second)  // for ping heartbeat
    defer func() {
        ticker.Stop()
        c.conn.Close()
    }()

    for {
        select {
        case msg, ok := <-c.sendCh:
            c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
            if !ok {
                // Channel closed — send a close message
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                return
            }

        case <-ticker.C:
            // Send a ping periodically to detect dead connections
            c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

// readPump — reads messages from the client
func (c *Client) readPump() {
    defer func() {
        close(c.sendCh)
        c.conn.Close()
    }()

    c.conn.SetReadLimit(512 * 1024)  // max 512KB per message
    c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))

    // Reset the deadline every time there's a pong from the client
    c.conn.SetPongHandler(func(string) error {
        c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })

    for {
        _, msg, err := c.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err,
                websocket.CloseGoingAway,
                websocket.CloseAbnormalClosure) {
                log.Printf("WebSocket error: %v", err)
            }
            break
        }
        log.Printf("Received: %s", msg)
        c.sendCh <- msg  // echo back
    }
}
Never write to a *websocket.Conn from more than one goroutine. This causes a panic with the message concurrent write to websocket connection. Always use a single writePump goroutine that receives messages from a channel.

The Hub Pattern — Broadcasting to Many Clients #

For multi-client applications (chat, real-time notifications), use a Hub that manages all connections in a single goroutine:

type Hub struct {
    clients    map[*Client]bool
    register   chan *Client
    unregister chan *Client
    broadcast  chan []byte
}

func NewHub() *Hub {
    return &Hub{
        clients:    make(map[*Client]bool),
        register:   make(chan *Client),
        unregister: make(chan *Client),
        broadcast:  make(chan []byte, 256),
    }
}

// Run is a single goroutine — the only one accessing the clients map
// no mutex needed because only one goroutine accesses this map
func (h *Hub) Run() {
    for {
        select {
        case c := <-h.register:
            h.clients[c] = true
            log.Printf("New client. Total: %d", len(h.clients))

        case c := <-h.unregister:
            if _, ok := h.clients[c]; ok {
                delete(h.clients, c)
                close(c.sendCh)
            }

        case msg := <-h.broadcast:
            for c := range h.clients {
                select {
                case c.sendCh <- msg:
                default:
                    // sendCh full — the client is too slow, disconnect
                    close(c.sendCh)
                    delete(h.clients, c)
                }
            }
        }
    }
}

ReadJSON and WriteJSON #

Helpers for JSON encode/decode without manual steps:

// Send a struct as JSON
type Event struct {
    Type    string      `json:"type"`
    Payload interface{} `json:"payload"`
    Time    string      `json:"time"`
}

err := conn.WriteJSON(Event{
    Type:    "notification",
    Payload: "Your order has been packed!",
    Time:    time.Now().Format(time.RFC3339),
})

// Receive and decode JSON
var cmd Event
if err := conn.ReadJSON(&cmd); err != nil {
    log.Println("ReadJSON error:", err)
    break
}
fmt.Println("Command:", cmd.Type)

WebSocket Clients in Go #

For testing or service-to-service communication over WebSocket:

func connectWS(serverURL string) {
    conn, _, err := websocket.DefaultDialer.Dial(serverURL, nil)
    if err != nil {
        log.Fatal("Dial:", err)
    }
    defer conn.Close()

    // Goroutine to receive messages from the server
    go func() {
        for {
            _, msg, err := conn.ReadMessage()
            if err != nil {
                log.Println("Read error:", err)
                return
            }
            log.Printf("Server: %s", msg)
        }
    }()

    // Send messages to the server
    for i := 0; i < 5; i++ {
        msg := fmt.Sprintf(`{"seq":%d,"text":"Hello!"}`, i)
        if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
            log.Println("Write error:", err)
            return
        }
        time.Sleep(time.Second)
    }
}

Complete Example Program — Real-Time Dashboard #

The following program builds a real-time dashboard that sends metric data to all connected clients every second, including an HTML page that can be opened directly in the browser:

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "math/rand"
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

type Metrics struct {
    Timestamp   string  `json:"timestamp"`
    CPUUsage    float64 `json:"cpu_usage"`
    MemoryUsage float64 `json:"memory_usage"`
    RequestsPS  int     `json:"requests_per_second"`
    ActiveConns int     `json:"active_connections"`
}

type WSMessage struct {
    Type    string      `json:"type"`
    Payload interface{} `json:"payload"`
}

type Client struct {
    conn   *websocket.Conn
    sendCh chan []byte
    hub    *Hub
}

type Hub struct {
    clients    map[*Client]bool
    register   chan *Client
    unregister chan *Client
    broadcast  chan []byte
}

func NewHub() *Hub {
    return &Hub{
        clients:    make(map[*Client]bool),
        register:   make(chan *Client),
        unregister: make(chan *Client),
        broadcast:  make(chan []byte, 64),
    }
}

func (h *Hub) Run() {
    for {
        select {
        case c := <-h.register:
            h.clients[c] = true
            log.Printf("[HUB] Client connected. Total: %d", len(h.clients))
            welcome, _ := json.Marshal(WSMessage{Type: "welcome",
                Payload: fmt.Sprintf("%d active clients", len(h.clients))})
            c.sendCh <- welcome

        case c := <-h.unregister:
            if _, ok := h.clients[c]; ok {
                delete(h.clients, c)
                close(c.sendCh)
                log.Printf("[HUB] Client disconnected. Total: %d", len(h.clients))
            }

        case msg := <-h.broadcast:
            for c := range h.clients {
                select {
                case c.sendCh <- msg:
                default:
                    close(c.sendCh)
                    delete(h.clients, c)
                }
            }
        }
    }
}

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin:     func(r *http.Request) bool { return true },
}

func (c *Client) writePump() {
    ticker := time.NewTicker(30 * time.Second)
    defer func() { ticker.Stop(); c.conn.Close() }()

    for {
        select {
        case msg, ok := <-c.sendCh:
            c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
            if !ok {
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                return
            }
        case <-ticker.C:
            c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

func (c *Client) readPump() {
    defer func() { c.hub.unregister <- c; c.conn.Close() }()

    c.conn.SetReadLimit(4096)
    c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
    c.conn.SetPongHandler(func(string) error {
        c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })

    for {
        _, _, err := c.conn.ReadMessage()
        if err != nil {
            break
        }
    }
}

func serveWS(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }
    c := &Client{conn: conn, sendCh: make(chan []byte, 256), hub: hub}
    hub.register <- c
    go c.writePump()
    c.readPump()
}

func generateMetrics(hub *Hub) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    cpu, mem, rps := 45.0, 60.0, 150

    for range ticker.C {
        if len(hub.clients) == 0 {
            continue
        }
        cpu += (rand.Float64() - 0.5) * 5
        if cpu < 5 { cpu = 5 } else if cpu > 98 { cpu = 98 }
        mem += (rand.Float64() - 0.5) * 3
        if mem < 20 { mem = 20 } else if mem > 95 { mem = 95 }
        rps += int((rand.Float64() - 0.5) * 30)
        if rps < 50 { rps = 50 } else if rps > 1000 { rps = 1000 }

        msg, _ := json.Marshal(WSMessage{Type: "metrics", Payload: Metrics{
            Timestamp:   time.Now().Format("15:04:05"),
            CPUUsage:    float64(int(cpu*10)) / 10,
            MemoryUsage: float64(int(mem*10)) / 10,
            RequestsPS:  rps,
            ActiveConns: len(hub.clients),
        }})
        hub.broadcast <- msg
    }
}

const dashboardHTML = `<!DOCTYPE html><html>
<head><meta charset="UTF-8"><title>Go Dashboard</title>
<style>
body{font-family:monospace;background:#0d1117;color:#c9d1d9;margin:40px}
h1{color:#58a6ff}
.grid{display:flex;gap:16px;flex-wrap:wrap;margin-top:20px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;min-width:160px;text-align:center}
.label{color:#8b949e;font-size:12px;margin-bottom:8px}
.value{font-size:32px;font-weight:bold;color:#58a6ff}
#status{padding:6px 12px;border-radius:4px;display:inline-block;font-size:13px}
.ok{background:#1f6feb33;color:#58a6ff}
.err{background:#f8514933;color:#f85149}
#log{margin-top:20px;background:#161b22;border:1px solid #30363d;border-radius:8px;
    padding:12px;height:120px;overflow-y:auto;font-size:12px;color:#8b949e}
</style></head>
<body>
<h1>⚡ Go Real-Time Dashboard</h1>
<span id="status" class="err">● Connecting...</span>
<div class="grid">
  <div class="card"><div class="label">CPU Usage</div><div class="value" id="cpu">-</div></div>
  <div class="card"><div class="label">Memory</div><div class="value" id="mem">-</div></div>
  <div class="card"><div class="label">Req/s</div><div class="value" id="rps">-</div></div>
  <div class="card"><div class="label">WS Clients</div><div class="value" id="conn">-</div></div>
  <div class="card"><div class="label">Time</div><div class="value" id="ts" style="font-size:20px">-</div></div>
</div>
<div id="log"></div>
<script>
const ws=new WebSocket('ws://'+location.host+'/ws');
const log=(msg)=>{const el=document.getElementById('log');el.innerHTML+=msg+'<br>';el.scrollTop=el.scrollHeight};
ws.onopen=()=>{document.getElementById('status').className='ok';document.getElementById('status').textContent='● Connected';log('✓ WebSocket connected')};
ws.onclose=()=>{document.getElementById('status').className='err';document.getElementById('status').textContent='○ Disconnected';log('✗ Connection lost')};
ws.onmessage=(e)=>{
  const msg=JSON.parse(e.data);
  if(msg.type==='welcome'){log('ℹ '+msg.payload);return}
  if(msg.type==='metrics'){
    const d=msg.payload;
    document.getElementById('cpu').textContent=d.cpu_usage+'%';
    document.getElementById('mem').textContent=d.memory_usage+'%';
    document.getElementById('rps').textContent=d.requests_per_second;
    document.getElementById('conn').textContent=d.active_connections;
    document.getElementById('ts').textContent=d.timestamp;
  }
};
</script></body></html>`

func main() {
    hub := NewHub()
    go hub.Run()
    go generateMetrics(hub)

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html")
        fmt.Fprint(w, dashboardHTML)
    })
    mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        serveWS(hub, w, r)
    })

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
    }
    log.Println("Dashboard: http://localhost:8080")
    log.Fatal(srv.ListenAndServe())
}

Summary #

  • WebSocket turns HTTP into a persistent two-way connection via an upgrade handshake — client and server can send to each other at any time.
  • gorilla/websocket is the de-facto standard library — go get github.com/gorilla/websocket.
  • Writes are NOT thread-safe — use a single writePump goroutine receiving messages from a channel; never write from more than one goroutine.
  • CheckOrigin must be implemented correctly in production to prevent CSRF over WebSocket.
  • SetReadLimit prevents clients from sending giant messages that exhaust memory.
  • Ping/pong heartbeat — send pings from writePump periodically; set a read deadline and reset it in the PongHandler.
  • The Hub pattern manages all clients in one goroutine — no mutex needed because the clients map is only accessed from a single goroutine.
  • IsUnexpectedCloseError distinguishes normal closes from real errors.
  • WriteJSON / ReadJSON as shortcuts for JSON encode/decode.
  • A full sendCh signals a slow client — disconnect it rather than hanging the server.

← Previous: Sockets   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact