39.3 Reading and Writing Messages in a WebSocket Handler
Right, so you’ve got your WebSocket connection open. Congratulations, the hard part is over. Now for the fun part: actually using the damn thing. This is where we move from handshakes and protocols to the actual business of shoving data back and forth. It’s simple in theory, but Go’s concurrency model means we get to do it the right way, which is both a blessing and a curse. Let’s break it down.
The core of your handler will be a loop. You read a message, you do something with it, maybe you write a message back. Rinse and repeat until the connection closes. The github.com/gorilla/websocket package (which you absolutely should be using, the standard golang.org/x/net/websocket is, to put it politely, not my first choice) gives us a very clean API for this.
The Basic Read/Write Loop
Here’s the skeleton of your handler’s life. You get a *websocket.Conn from the upgrade, and you enter a for loop.
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Failed to upgrade connection: %v", err)
return
}
defer conn.Close() // This is non-negotiable. Always close.
for {
// 1. Read a message
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Printf("Read error: %v", err)
break // Break out of the loop on error
}
// 2. Do something profoundly interesting with the message
log.Printf("Received: %s (type: %d)", string(p), messageType)
// 3. Write a response
processedMessage := []byte(fmt.Sprintf("You said: %s", string(p)))
err = conn.WriteMessage(messageType, processedMessage)
if err != nil {
log.Printf("Write error: %v", err)
break
}
}
}
This works. It’s fine. For a toy example. But we’re not building toys, are we? We’re building systems. The first glaring issue is that this loop is blocking. If processing the message takes a long time (say, a database query), your entire connection is stuck waiting. You can’t read the next message. This is a terrible user experience. We need concurrency.
Handling Concurrency Correctly (This is the Important Bit)
The *websocket.Conn object, bless its heart, is not safe for concurrent access. The documentation screams this at you. You cannot have one goroutine reading from it while another is writing to it. You can, however, have one goroutine dedicated to reading and another dedicated to writing. This is the canonical pattern.
We use channels. The read goroutine will funnel incoming messages into a channel. The write goroutine will receive messages on a channel and send them out. This separates the concerns beautifully and is thread-safe.
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Failed to upgrade: %v", err)
return
}
// Buffered channels to prevent deadlocks under load.
// Size them according to your expected throughput.
toClient := make(chan []byte, 10)
fromClient := make(chan []byte, 10)
// Goroutine 1: The dedicated reader
go func() {
defer conn.Close() // Ensure connection closes if reader dies
for {
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("Read error: %v", err)
close(fromClient) // Signal that no more reads are coming
return
}
fromClient <- message
}
}()
// Goroutine 2: The dedicated writer
go func() {
defer conn.Close() // Ensure connection closes if writer dies
for {
select {
case message, ok := <-toClient:
if !ok {
// Channel was closed, initiate a clean close.
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
log.Printf("Write error: %v", err)
return
}
}
}
}()
// Main handler goroutine: The "business logic"
// This is where you process messages from 'fromClient' and decide what to send to 'toClient'.
for message := range fromClient {
log.Printf("Processing: %s", message)
// Do your work here. This is no longer blocking the socket!
response := []byte(fmt.Sprintf("Processed: %s", message))
toClient <- response
}
// When the fromClient channel closes, close the toClient channel to signal the writer to shut down.
close(toClient)
}
This pattern is robust. It handles errors, it cleans up resources, and it properly separates I/O from logic. This is what you should be building.
Message Types and The Binary/Text Divide
You probably noticed the messageType in the read. The WebSocket protocol has two main data types: TextMessage (for UTF-8 encoded text, like JSON) and BinaryMessage (for everything else, like protobufs or images). It’s considered good form to respond with the same type you received. The pattern above hardcodes TextMessage for simplicity, but in a production system, you’d want to pass the message type along with the payload, perhaps using a struct like type WsMessage struct { Type int; Data []byte } for your channels.
The Elephant in the Room: Connection Closure
WebSocket connections die. Networks are flaky, users close tabs, servers restart. You must handle this gracefully. The ReadMessage() call will return an error when the connection closes. The specific error type *websocket.CloseError is particularly useful—it tells you the close code and reason sent by the peer. You can use this to log the reason for closure and to decide if you should attempt a reconnect from the client side. Always break out of your loops and close() your channels on error. This triggers the cleanup in your deferred statements and other goroutines.