Right, let’s talk about gorilla/websocket. If you’re doing WebSockets in Go, this is the library you reach for. It’s not technically the standard library, but it might as well be. It’s the de facto standard, battle-tested, and frankly, it’s excellent. The Go team themselves even maintains a link to it on the official golang.org website, which is about as close to a royal seal of approval as you get in our world.

The first thing you need to understand is that it operates on a different level of abstraction than the net/http package. http.ResponseWriter and http.Request are for the initial HTTP handshake. After that, the library “upgrades” that connection, hijacks the underlying TCP connection, and gives you a *websocket.Conn to play with. This connection is a full-duplex, persistent channel. It’s your responsibility to manage it, read from it, write to it, and—this is the part everyone forgets—close it properly.

The Upgrade: From HTTP to WebSocket

The journey begins with an HTTP handler. A client sends a regular HTTP request with some special headers saying, “Hey, I’d like to upgrade this conversation to WebSockets, if you don’t mind.” Your job is to check if you do mind and then perform the upgrade.

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

// We set up an Upgrader. This is where you define your policy.
var upgrader = websocket.Upgrader{
    // This is a security-critical check. Do NOT just return 'true' in production.
    // You must check the Origin header to prevent CSRF attacks.
    CheckOrigin: func(r *http.Request) bool {
        // For now, we'll allow any origin. See the next section for why this is a terrible idea.
        return true
    },
    // You can also set read and write buffer sizes here. The library's defaults are sensible.
}

func websocketHandler(w http.ResponseWriter, r *http.Request) {
    // This is the magic line. It takes the HTTP connection and upgrades it.
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        // The Upgrade function handles sending the error HTTP response for failed handshakes.
        log.Printf("Upgrade failed: %v", err)
        return
    }
    defer conn.Close() // This is non-negotiable. Always defer the close.

    // Now, 'conn' is a live WebSocket connection. The HTTP request is over.
    // Your application logic for handling this connection goes here.
    for {
        messageType, message, err := conn.ReadMessage()
        if err != nil {
            // This is how you know the client disconnected or something went wrong.
            log.Println("Read error:", err)
            break // Break out of the loop to end the handler, which will run the defer.
        }
        log.Printf("Received: %s", message)
        // Echo it back, because what else are we gonna do, be productive?
        err = conn.WriteMessage(messageType, message)
        if err != nil {
            log.Println("Write error:", err)
            break
        }
    }
}

The CheckOrigin Pitfall

See that CheckOrigin: func(r *http.Request) bool? This is the biggest foot-gun in the entire package. If you set it to return true always (which is tempting for quick tests), you are opening a massive security hole. Any website any user visits can send a request to your WebSocket endpoint from the user’s browser. You must validate the Origin header against your expected set of hosts. Don’t be the person who deploys this to production with CheckOrigin: nil (which defaults to a safe, but overly restrictive, same-origin policy).

// A production-ready CheckOrigin function
var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        // Replace with your actual allowed origins
        return origin == "https://mytrustedapp.com" || origin == "https://mytrustedapp.example.com"
    },
}

Reading and Writing: It’s More Than Just Messages

The simple loop using ReadMessage() and WriteMessage() is fine, but the library gives you more control. The NextReader() and NextWriter() methods are more efficient if you’re dealing with large messages or streaming data, as they avoid unnecessary allocations.

But here’s the crucial part: You must read from the connection. If you don’t, the connection will eventually stall. The write buffer will fill up, and everything will grind to a halt. This is why you almost always see the read loop happen in its own goroutine. Your main handling goroutine might be busy doing work, but you need a dedicated reader to process pings, close messages, and normal traffic to keep the connection healthy.

The Concurrency Model

This is the most important thing to internalize: A *websocket.Conn supports one concurrent reader and one concurrent writer. That’s it. The documentation states this explicitly, and you ignore it at your peril. You can’t have two goroutines calling conn.ReadMessage() at the same time; you’ll get a race condition. The same goes for writing.

The standard pattern is:

  1. One goroutine that does nothing but read from the connection (handling pings, pongs, and incoming messages, and sending them into a channel for processing).
  2. One goroutine that reads from a channel and writes messages to the connection.
  3. Your main application logic runs elsewhere and communicates with these two goroutines via channels.

This pattern neatly sidesteps the concurrency rule and makes your application much cleaner and safer. The library provides conn.SetReadDeadline() and SetWriteDeadline(), which are essential for detecting dead clients. Always set a deadline, especially for reads. A ping/pong heartbeat (which the library has helpers for) is often used to keep the connection alive and reset this deadline.

The Close Handshake: Don’t Just Hang Up

WebSockets have a closing handshake. You don’t just drop the TCP connection like a barbarian. You send a close message. The good news is that defer conn.Close() handles this for you. It sends a close message and then closes the underlying network connection. However, you should also handle receiving a close message from the client.

When conn.ReadMessage() returns an error, you should check if it’s a “normal” close using websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway). If it is, you can just break your loop and let the defer handle the rest. If it’s an unexpected error, you might want to log it. The key is that the Close() method is idempotent—calling it multiple times is safe.