Go

How do you implement WebSockets in Go? How do you handle concurrent writes, ping/pong keepalives and backpressure?

Question 574HardGo 1.22 to 1.25

The standard library has no WebSocket server. Use github.com/gorilla/websocket or github.com/coder/websocket (formerly nhooyr, context-based and safe for concurrent writes).

With gorilla, each connection allows one concurrent reader and one concurrent writer. Concurrent WriteMessage calls corrupt frames or panic. Close and WriteControl are the exceptions and are safe to call concurrently. The standard design is two goroutines per connection:

  • A read pump that owns reads and handles pongs.
  • A write pump that owns writes and sends pings.

Everyone else talks to the connection through a bounded channel. Backpressure means that when a client is too slow and its buffer fills, you drop the message or disconnect the client. You must never block the broadcaster.

const (
	writeWait  = 10 * time.Second
	pongWait   = 60 * time.Second
	pingPeriod = pongWait * 9 / 10
)

type client struct {
	conn *websocket.Conn
	send chan []byte // bounded, for example make(chan []byte, 256)
}

func (c *client) readPump(onMsg func([]byte)) {
	defer c.conn.Close()
	c.conn.SetReadLimit(64 << 10)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(string) error {
		return c.conn.SetReadDeadline(time.Now().Add(pongWait))
	})
	for {
		_, msg, err := c.conn.ReadMessage()
		if err != nil {
			return // timeout or close; the hub closes c.send on unregister
		}
		onMsg(msg)
	}
}

func (c *client) writePump() {
	t := time.NewTicker(pingPeriod)
	defer func() { t.Stop(); c.conn.Close() }()
	for {
		select {
		case msg, ok := <-c.send:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				c.conn.WriteMessage(websocket.CloseMessage, nil)
				return
			}
			if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
				return
			}
		case <-t.C:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

// broadcaster: never block on a slow client
func (c *client) trySend(msg []byte) bool {
	select {
	case c.send <- msg:
		return true
	default:
		return false // buffer full: unregister and close this client
	}
}

Gotchas:

  • Set a read limit, or a client can send a huge frame and exhaust memory.
  • Check the Origin header with Upgrader.CheckOrigin to prevent cross-site WebSocket hijacking.
  • Deadlines are how you detect dead TCP peers. Without them, goroutines leak.
  • http.Server.Shutdown does not close hijacked connections. Track them and close them yourself.
  • Only one goroutine may close the send channel. Let the hub own it.

More on Observability, Debugging & Production Operations

All 14 Observability, Debugging & Production Operations questions