The persistent pipe
You want a chat room where a message typed in one browser appears on three others instantly. HTTP requests are too slow for that. Each request opens a TCP connection, delivers a payload, and closes. The round trip alone adds latency. You need a persistent, bidirectional pipe that stays open for the entire session. WebSockets provide that pipe. Go provides the concurrency primitives to manage thousands of them without melting your CPU.
How the upgrade works
WebSockets start life as a normal HTTP request. The browser sends a special Upgrade: websocket header along with a cryptographic challenge. The server validates the challenge, replies with a 101 status code, and keeps the TCP socket open. The application layer swaps HTTP for the WebSocket frame format. After the handshake, both sides can send data anytime. There are no more request-response cycles. Just a continuous stream of frames.
In Go, you handle this with an Upgrader. The upgrader reads the HTTP headers, validates the handshake, and returns a *websocket.Conn. That connection object replaces the http.ResponseWriter. You stop writing HTTP responses and start calling ReadMessage and WriteMessage. The underlying TCP socket is the same. Only the framing changes. Go's standard library deliberately omits WebSocket support. The community relies on third-party packages like gorilla/websocket because the protocol involves subtle state machines and masking rules that are tedious to maintain. Using a battle-tested library saves you from reinventing frame parsing.
The message bus pattern
A chat server needs two jobs. It must read messages from every connected client. It must deliver every message to every other client. Running a read loop per client is straightforward. Broadcasting to everyone is where concurrency gets tricky. If you write directly to every client inside the read loop, a slow client blocks the entire server. One frozen browser stalls every other user. The Go scheduler cannot help you if a goroutine is stuck in a blocking network write.
The solution is a shared channel. Think of it as a public bulletin board. Every client goroutine drops its message on the board. A single broadcaster goroutine watches the board, grabs each message, and fans it out to all connected clients. The channel decouples reading from writing. Fast clients do not wait for slow ones. The broadcaster controls the pacing. Channels in Go are built on lightweight mutexes and wait queues. They are the idiomatic way to pass data between goroutines without shared memory.
Here is the hub that manages the board and the client registry.
package main
import (
"sync"
"github.com/gorilla/websocket"
)
// Hub coordinates connected clients and message distribution.
type Hub struct {
mu sync.Mutex
clients map[*websocket.Conn]bool
broadcast chan []byte
}
// NewHub initializes the registry with a buffered message channel.
func NewHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte, 256), // buffer prevents read loops from blocking
}
}
// Run starts the fan-out loop that delivers messages to every client.
func (h *Hub) Run() {
for msg := range h.broadcast {
h.mu.Lock()
for client := range h.clients {
// write fails if the client disconnected or the network dropped
if err := client.WriteMessage(websocket.TextMessage, msg); err != nil {
client.Close()
delete(h.clients, client)
}
}
h.mu.Unlock()
}
}
The channel buffer is the quiet hero here. Without it, the read loop would block on broadcast <- msg whenever the broadcaster is busy writing to a slow client. A buffer of 256 frames gives the read loops breathing room. The mutex protects the client map. Go maps are not thread-safe. Two goroutines reading and deleting at the same time will trigger a runtime panic. The lock ensures only one goroutine touches the registry at a time. Accept interfaces, return structs. The hub exposes a concrete type because callers need to call Run() and access the broadcast channel. You hide the internal map behind the mutex.
The connection handler
The HTTP handler performs the upgrade and starts the read loop. It registers the connection, reads frames, and forwards them to the hub. When the connection closes, it unregisters itself. The handler runs in its own goroutine, managed by the net/http server. Each incoming connection gets its own goroutine. The Go runtime schedules them efficiently across OS threads. You do not manage thread pools manually.
Here is the handler that wires the connection to the hub.
import (
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ServeWSS upgrades the HTTP request and runs the read loop.
func ServeWSS(h *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "upgrade failed", http.StatusInternalServerError)
return
}
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
conn.Close()
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
h.broadcast <- msg
}
}
The CheckOrigin callback controls which browsers are allowed to connect. In production, you validate the origin header against a whitelist. The defer block guarantees cleanup. If the read loop breaks due to a network error, the connection is removed from the registry and the socket closes. The receiver name h follows Go convention: one or two letters matching the type. You rarely see self or this in idiomatic Go code. The if err != nil { return } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally ignore a failed upgrade.
Walking through the runtime
When the server starts, http.ListenAndServe begins accepting TCP connections. A browser visits the page and opens a WebSocket. The ServeWSS function upgrades the connection. The new *websocket.Conn is added to the hub map. The for loop blocks on ReadMessage. The connection sits idle, waiting for the user to type something. The goroutine is parked. It consumes zero CPU cycles.
The user hits enter. The browser sends a text frame. ReadMessage unblocks, decodes the payload, and pushes it to h.broadcast. The broadcaster goroutine, which has been sleeping on <-h.broadcast, wakes up. It grabs the byte slice, locks the mutex, and iterates over the client map. It calls WriteMessage on each connection. The frame travels over TCP to every browser. The browser renders the message. The loop continues.
If a user closes the tab, the TCP connection drops. ReadMessage returns an error. The if err != nil { break } pattern triggers. The deferred cleanup runs. The connection is deleted from the map. The socket closes. The broadcaster never tries to write to a dead connection again. Channels are not magic. They are just a synchronized queue. If nobody reads, the sender blocks. If nobody writes, the receiver blocks. The hub keeps both sides moving.
Realistic production patterns
Real chat servers need more than a raw message loop. They need graceful shutdown, keepalive pings, and context-aware cancellation. The context.Context type always goes as the first parameter in Go functions that might be long-lived. It carries deadlines and cancellation signals. You thread it through your handler so the server can stop cleanly when you press Ctrl+C. Context is plumbing. Run it through every long-lived call site.
Here is how a production handler respects context and handles keepalives.
import (
"context"
"time"
"github.com/gorilla/websocket"
)
// ServeWSSWithContext handles upgrades, keepalives, and graceful shutdown.
func ServeWSSWithContext(ctx context.Context, h *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "upgrade failed", http.StatusInternalServerError)
return
}
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
conn.Close()
}()
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)); return nil })
for {
select {
case <-ctx.Done():
return
default:
_, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
// log unexpected disconnects
}
return
}
h.broadcast <- msg
}
}
}
The select statement watches both the context and the connection. When the context cancels, the handler exits cleanly. The read deadline forces ReadMessage to return an error if the client goes silent for sixty seconds. The pong handler resets the deadline when the client responds to a ping. This pattern prevents goroutine leaks. A disconnected client that never sends a FIN packet would otherwise block forever. The deadline forces the read loop to break and run cleanup. The worst goroutine bug is the one that never logs. Always track unexpected disconnects.
Common pitfalls
Forgetting the mutex on the client map is the most frequent bug. Go will not stop you at compile time. The program runs, then crashes under load with a concurrent map read and map write panic. Always protect shared maps with sync.Mutex or sync.RWMutex. The race detector catches this during testing. Run go run -race before deploying.
Sending on an unbuffered channel while the broadcaster is blocked will deadlock your read loops. If the broadcaster hangs writing to a slow client, every new message blocks the sender. The server freezes. Buffer the channel or use a select with a timeout to drop messages when the system is overwhelmed. The compiler rejects the program with cannot use string as []byte in argument if you mix up frame types. It catches mistakes before they reach production.
Goroutine leaks happen when the read loop never exits. If you swallow the error from ReadMessage instead of breaking, the goroutine stays alive, holding the TCP socket and the map entry. The memory grows until the process crashes. Always break on error. Always run cleanup in a defer. Do not pass a *string. Strings are already cheap to pass by value. The compiler optimizes string header copies automatically.
The compiler complains with undefined: websocket if you forget the import. It rejects the file with imported and not used if you leave it behind. The tooling is strict by design. gofmt runs automatically in most editors. Do not argue about indentation. Let the formatter decide. You save mental energy for architecture.
When to use WebSockets
Use a WebSocket when you need bidirectional, low-latency communication like a chat room, collaborative editor, or live trading dashboard. Use Server-Sent Events when the server only pushes data to the client and you want native browser support without third-party libraries. Use HTTP long-polling when you must traverse restrictive corporate proxies that terminate persistent connections. Use plain HTTP requests when the data updates infrequently and latency does not impact the user experience.