The persistent pipe
You're building a live dashboard. The client needs stock prices, sensor readings, or chat messages the instant they happen. Polling the server every second wastes bandwidth, adds latency, and overwhelms the backend with idle requests. You need a persistent connection where the server can push data to the client at will. That's where WebSockets come in. They turn a standard HTTP request into a full-duplex pipe.
HTTP works like postal mail. The client sends a letter, waits for a reply, and the conversation ends. WebSockets work like a phone call. You dial in, the connection stays open, and both sides can talk whenever they want. The protocol starts with an HTTP handshake to upgrade the connection, then switches to a binary frame format for efficiency. gorilla/websocket handles the handshake and frame parsing so you don't have to implement the RFC yourself.
Minimal echo server
Here's the skeleton: an upgrader, a route, and a listener. The upgrader configures the handshake. The handler upgrades the request and loops on messages.
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
// upgrader handles the HTTP-to-WebSocket handshake.
// CheckOrigin allows connections from any domain for this example.
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func main() {
http.HandleFunc("/ws", handler)
// ListenAndServe blocks until the process exits.
log.Fatal(http.ListenAndServe(":8080", nil))
}
The handler does the heavy lifting: upgrade, loop, echo.
// handler upgrades the request and echoes messages back to the client.
func handler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer conn.Close()
for {
// ReadMessage blocks until data arrives or the connection fails.
_, message, err := conn.ReadMessage()
if err != nil {
break
}
// WriteMessage sends the payload back. Using the same type preserves text/binary semantics.
err = conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
break
}
}
}
WebSockets are persistent. Close them or leak them.
What happens under the hood
When the server starts, http.ListenAndServe waits for connections. A client hits /ws. The handler runs. upgrader.Upgrade checks the request headers. If the client sent Upgrade: websocket, the library switches protocols. The HTTP response code becomes 101 Switching Protocols. The connection is now a WebSocket.
ReadMessage blocks. The client sends "hello". ReadMessage returns. WriteMessage sends it back. The loop continues. If the client closes the tab, ReadMessage returns an error, the loop breaks, defer conn.Close() runs, and the goroutine exits.
Go convention dictates that error handling is explicit. You'll see if err != nil { return err } everywhere. It looks verbose, but it makes the failure path impossible to miss. The receiver name matches the type. (c *client) is standard. (this *client) is not. Trust gofmt. Argue logic, not formatting.
Handling concurrent writes
Real apps often need to broadcast messages or handle logic between read and write. The library's WriteMessage is not safe for concurrent calls. If two goroutines try to write at once, the connection deadlocks. The standard solution is a dedicated writer goroutine fed by a channel.
The client struct holds the connection and a send channel.
type client struct {
conn *websocket.Conn
send chan []byte
}
// readPump runs in a separate goroutine to handle incoming messages.
// It pushes data to the send channel instead of writing directly.
func (c *client) readPump() {
defer c.conn.Close()
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}
// Send message to the write goroutine via channel.
c.send <- message
}
}
The writer goroutine consumes the channel and writes safely.
// writePump runs in a separate goroutine to handle outgoing messages.
// It ensures only one write happens at a time, preventing deadlocks.
func (c *client) writePump() {
defer c.conn.Close()
for message := range c.send {
err := c.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
break
}
}
}
Write safety is a contract. Use a writer goroutine or accept the deadlock.
The ping-pong heartbeat
Firewalls and load balancers kill idle connections. You need ping and pong frames to keep the link alive. The server sends a ping. The client responds with a pong. If the pong doesn't arrive, the server closes the connection.
Set a read deadline and a pong handler. The pong handler resets the deadline every time a pong arrives.
const pongWait = 60 * time.Second
// SetReadDeadline forces ReadMessage to fail if no data arrives within pongWait.
conn.SetReadDeadline(time.Now().Add(pongWait))
// PongHandler resets the deadline when the client responds to a ping.
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
Ping and pong are heartbeats. Set read deadlines or the connection rots.
Pitfalls and errors
If you call WriteMessage from multiple goroutines, the library panics or deadlocks. The connection locks up and stops responding. If you forget to close the connection, the goroutine hangs forever waiting on ReadMessage. The server leaks memory for every disconnected client.
The compiler catches type mismatches. If you pass a string to WriteMessage instead of a byte slice, you get cannot use msg (variable of type string) as []byte value in argument. ReadMessage returns *websocket.CloseError when the client disconnects cleanly. Check websocket.IsCloseError(err, websocket.CloseNormalClosure) to distinguish normal closes from network failures.
In production, CheckOrigin must validate the Origin header. Returning true allows any site to open a WebSocket to your server, enabling cross-site WebSocket hijacking. Restrict origins to your domain.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. The worst goroutine bug is the one that never logs.
When to use WebSockets
Use gorilla/websocket when you need a battle-tested library with full RFC compliance and concurrent write safety helpers. Use HTTP long-polling when you must traverse restrictive firewalls that block WebSocket upgrades. Use Server-Sent Events when you only need one-way updates from server to client and want simpler browser support. Use gRPC streams when you are building internal microservices and need typed contracts with high performance. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.