How to Implement WebSockets in Go

Web
Implement WebSockets in Go by using the gorilla/websocket package to upgrade HTTP connections and handle bidirectional data streams.

The persistent pipe

You build a live dashboard or a multiplayer game lobby. Standard HTTP requests work perfectly for loading the initial page, but polling the server every two seconds for updates feels clunky. It wastes bandwidth, introduces latency, and forces the client to guess when new data arrives. You need a persistent, bidirectional channel where the server can push updates the exact moment they happen. That channel is a WebSocket.

A WebSocket begins as a regular HTTP request. The client sends an Upgrade: websocket header along with a few cryptographic keys. The server validates the request, responds with a 101 Switching Protocols status, and the TCP connection stays open. The framing protocol takes over. Instead of request-response cycles, both sides send lightweight frames of data whenever they want. Think of it like switching from sending postal letters to picking up a phone. The line stays active, and either party can speak without waiting for an invitation.

Go's standard library handles HTTP beautifully but deliberately leaves WebSocket implementation to third-party packages. The language philosophy favors small standard libraries and robust community packages. The de facto standard is gorilla/websocket. It wraps the raw TCP connection, handles the binary framing protocol, and manages the handshake so you don't have to parse headers manually.

WebSockets are persistent. Treat them like long-lived resources, not fire-and-forget requests.

How the upgrade handshake works

The browser initiates the connection using the WebSocket JavaScript API. Under the hood, it fires an HTTP GET request to your endpoint. The request includes Connection: Upgrade and Upgrade: websocket. Your Go server receives this as a standard http.Request.

You cannot process this request like a normal API route. If you write to the http.ResponseWriter, the HTTP transaction completes and the TCP connection closes. You must intercept the request and tell the HTTP server to hand over the underlying network connection. That is what the Upgrader does. It checks the origin, validates the handshake keys, writes the 101 response, and returns a *websocket.Conn object. From that point forward, you are no longer working with HTTP. You are working with a raw TCP socket that speaks the WebSocket framing protocol.

The framing protocol splits messages into frames. Each frame contains an opcode that tells the receiver whether the payload is text, binary, a ping, a pong, or a close signal. Large messages are fragmented into multiple frames. The gorilla/websocket package reassembles fragments automatically when you call ReadMessage. You rarely need to touch the raw frame API unless you are building a highly optimized streaming system.

The upgrade is a one-way door. Once the connection switches protocols, you cannot fall back to HTTP.

The minimal echo server

Here is the simplest working WebSocket server: spawn a listener, upgrade the connection, and bounce messages back.

package main

import (
	"log"
	"net/http"
	"github.com/gorilla/websocket"
)

// upgrader converts an HTTP connection into a WebSocket.
var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024, // pre-allocate memory for incoming frames
	WriteBufferSize: 1024, // pre-allocate memory for outgoing frames
}

func main() {
	http.HandleFunc("/ws", handler)
	http.ListenAndServe(":8080", nil)
}

The handler runs the read-write loop. It blocks on ReadMessage until data arrives, then immediately writes it back.

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() // ensures the TCP socket releases on exit
	for {
		_, message, err := conn.ReadMessage()
		if err != nil {
			break // connection closed or malformed frame
		}
		log.Printf("recv: %s", message)
		err = conn.WriteMessage(websocket.TextMessage, message)
		if err != nil {
			break
		}
	}
}

When a client connects, Upgrade swaps the HTTP transport for the WebSocket transport. The defer conn.Close() guarantees cleanup even if the loop breaks. ReadMessage blocks the goroutine until the client sends something. When it returns, you get the message type, the payload, and an error. If the error is nil, you echo the payload back using WriteMessage. If either call fails, the loop breaks and the deferred close runs.

The loop runs in a single goroutine. That keeps the code simple but creates a bottleneck: you cannot read and write at the same time.

Blocking reads are the default. Concurrency requires explicit design.

Handling real-world constraints

Production WebSockets need three things the minimal example lacks: keep-alive handling, graceful shutdown, and safe concurrent writes. Networks drop connections silently. Firewalls kill idle sockets. Clients disconnect without sending a close frame. You need a heartbeat mechanism to detect dead connections.

The WebSocket protocol includes ping and pong frames. The server sends a ping. The client automatically replies with a pong. If the pong never arrives, the connection is dead. The gorilla/websocket package lets you set a PingHandler and PongHandler on the upgrader. You also set a read deadline on the connection. If no frame arrives before the deadline, ReadMessage returns a timeout error and you close the connection.

Here is the upgrader configured for keep-alive:

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

func handler(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	// set read deadline to detect dead connections
	conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	conn.SetPongHandler(func(string) error {
		conn.SetReadDeadline(time.Now().Add(60 * time.Second))
		return nil // reset deadline on every pong
	})
	// spawn a dedicated goroutine to send periodic pings
	go pingLoop(conn)
	handleMessages(conn)
}

The ping loop runs in the background. It sends a ping frame every 30 seconds. If the connection dies, the write fails and the goroutine exits. The PongHandler resets the read deadline whenever the client responds. This pattern keeps idle connections alive through NAT gateways and load balancers.

You also need to handle graceful shutdown. Long-lived connections ignore SIGTERM by default. You should pass a context.Context to your handler and cancel it when the server shuts down. The context.Context always goes as the first parameter in Go, conventionally named ctx. Functions that accept a context must respect cancellation and deadlines.

Concurrent writes are the most common runtime bug. ReadMessage and WriteMessage are not safe to call from multiple goroutines simultaneously. If your handler spawns a goroutine to broadcast messages while the main loop is also writing, the program panics. The compiler rejects concurrent map writes with concurrent map writes, but it cannot catch concurrent WebSocket writes. You get a runtime panic: concurrent WriteMessage calls. The fix is a write mutex or a dedicated write channel.

Here is a safe write pattern using a channel and a dedicated goroutine:

type client struct {
	conn   *websocket.Conn
	send   chan []byte // buffered channel to queue outgoing messages
}

func (c *client) writePump() {
	ticker := time.NewTicker(30 * time.Second)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()
	for {
		select {
		case message, ok := <-c.send:
			if !ok {
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}
			err := c.conn.WriteMessage(websocket.TextMessage, message)
			if err != nil {
				return // connection broken, exit goroutine
			}
		case <-ticker.C:
			err := c.conn.WriteMessage(websocket.PingMessage, nil)
			if err != nil {
				return
			}
		}
	}
}

The writePump goroutine owns the connection for writing. All other parts of your application send data through the send channel. The channel serializes writes automatically. The select statement multiplexes queued messages and the periodic ping. If the channel closes or the write fails, the goroutine cleans up and exits.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.

Where things go wrong

WebSockets introduce stateful connections to a stateless web. That shift changes how you debug and deploy.

The most frequent issue is forgetting to close the connection. If a client disconnects abruptly, ReadMessage returns an error. If you ignore the error and keep looping, the goroutine stays alive, holding the TCP socket and memory. The compiler complains with imported and not used if you forget to import a package, but it will not warn you about leaked goroutines. You must break the loop on any error and rely on defer conn.Close().

Another common mistake is treating WebSockets like HTTP endpoints. You cannot use standard middleware like logging or authentication the same way. The upgrade happens before your route handler runs. You must validate tokens or check CORS in the CheckOrigin function or before calling Upgrade. If you skip validation, you open a persistent tunnel to your backend that bypasses your security layer.

Memory limits also matter. Each WebSocket connection allocates read and write buffers. If you set ReadBufferSize to 64KB and handle 10,000 concurrent connections, you are holding 640MB of RAM just for buffers. Size your buffers based on your actual message payload. Small text messages only need a few kilobytes.

The worst goroutine bug is the one that never logs. Always wrap connection handlers in a function that logs the connection ID and exit reason.

Choosing the right transport

WebSockets solve a specific problem. They are not a replacement for HTTP. Pick the transport that matches your data flow.

Use WebSockets when you need low-latency, bidirectional streaming where the server pushes updates frequently and the client must acknowledge or respond in real time. Use Server-Sent Events when you only need server-to-client streaming and want to leverage standard HTTP with automatic reconnection and browser-native support. Use HTTP polling when your update frequency is low and you want to avoid the operational complexity of persistent connections. Use gRPC streaming when you are building internal microservices and need typed, efficient binary payloads with built-in load balancing.

WebSockets are a pipe, not a protocol. Design your application around the pipe, not the other way around.

Where to go next