Alright, let’s talk about the new kid on the block. You’ve probably heard of Gorilla/websocket—it’s been the de facto standard for years. But its maintainers have gracefully placed it into maintenance mode, which is a polite way of saying, “Maybe don’t start your new, mission-critical project with this.” Enter nhooyr.io/websocket (by the brilliant Eliot Nhooyer), a modern library that feels like it was designed specifically for the Go we write today: context-aware, io-friendly, and blessedly straightforward.

I switched to this library for one simple reason: it makes the easy things trivial and the complex things possible without having to fight its own API. It embraces contexts, standard reader/writer interfaces, and provides sensible defaults. It feels like Go.

The Basic Handshake: Dialing and Accepting

Forget the convoluted setup you might remember. Connecting with nhooyr is a lesson in simplicity. To dial a server, you use websocket.Dial. To accept a connection from an HTTP handler, you use websocket.Accept. See? Sensible.

Here’s a quick example. First, a bare-bones server using the standard http.Handler pattern:

// Server
func main() {
    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        // Accept the connection, setting origin checks to none for this example.
        // WARNING: In production, you MUST validate the origin! More on that later.
        conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
            OriginPatterns: []string{"*"}, // Don't do this in real life.
        })
        if err != nil {
            log.Printf("Failed to accept WebSocket connection: %v", err)
            return
        }
        defer conn.Close(websocket.StatusInternalError, "Connection closing")

        // Now use the connection...
        ctx := context.Background()
        err = echo(ctx, conn)
        if err != nil {
            log.Printf("Echo error: %v", err)
        }
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}

func echo(ctx context.Context, conn *websocket.Conn) error {
    for {
        // Read a message
        _, msg, err := conn.Read(ctx)
        if err != nil {
            return err
        }
        log.Printf("Received: %s", msg)

        // Write it right back
        err = conn.Write(ctx, websocket.MessageText, msg)
        if err != nil {
            return err
        }
    }
}

And here’s a client dialing in:

// Client
func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()

    // Dial the server
    conn, _, err := websocket.Dial(ctx, "ws://localhost:8080/ws", nil)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close(websocket.StatusInternalError, "Closing")

    // Send a message
    err = conn.Write(ctx, websocket.MessageText, []byte("Hello, WebSocket!"))
    if err != nil {
        log.Fatal(err)
    }

    // Read the echo
    _, msg, err := conn.Read(ctx)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Received echo: %s", msg)

    conn.Close(websocket.StatusNormalClosure, "")
}

Notice the context.Context everywhere? That’s not an accident. This library is built for a world where we need to manage timeouts and cancellations properly. The connection’s Read and Write methods respect context cancellation, which is an absolute godsend for building robust applications. No more stuck goroutines because you forgot to set a SetDeadline.

Message Types: Text, Binary, and the Read API

The conn.Read method returns three things: the message type (websocket.MessageType), the byte slice of the message itself, and an error. The type is almost always either MessageText or MessageBinary.

Here’s the crucial part: the library does not allow you to read a text message as binary or vice-versa. It’s strict. This is a feature, not a bug. It forces you to be explicit about the data contract of your application. If your frontend sends JSON (text), your Go code damn well better be expecting MessageText. This strictness prevents a whole class of subtle data marshaling bugs.

The Writer’s Advantage: io.WriteCloser

This is one of my favorite features. While Read gives you a discrete message, Write lets you send messages in two ways. You already saw the simple conn.Write for sending a whole []byte. But for larger payloads? You can get an io.WriteCloser for a single message.

// For a large message, stream it instead of loading it all into memory.
w, err := conn.Writer(ctx, websocket.MessageBinary)
if err != nil {
    return err
}
defer w.Close()

// Now 'w' is a standard io.Writer. Use it with encoders, io.Copy, etc.
encoder := json.NewEncoder(w)
err = encoder.Encode(myGiantDataStructure)
if err != nil {
    return err
}
// Remember to Close() it to actually send the frame!

This is incredibly powerful. You can seamlessly stream large amounts of data without holding the entire payload in memory, hooking directly into Go’s vast ecosystem of io utilities.

The Pitfall: Origin Checking is YOUR Job

Pay attention. This is the most common foot-gun. The AcceptOptions.OriginPatterns field is critical for security. It prevents malicious sites from making a WebSocket connection to your server from a user’s browser.

My example used "*" to accept any origin. This is a terrible idea for anything other than a toy example. In production, you must explicitly whitelist the origins you expect.

// Do this instead.
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
    OriginPatterns: []string{"https://myproductionapp.com", "https://www.myproductionapp.com"},
})

If you get this wrong, you’ve introduced a significant security vulnerability. The library can’t guess your allowed origins, so it defaults to being strict and will reject requests without a valid pattern. You have to consciously and correctly configure it.

Graceful Closure: Status Codes Matter

Did you notice all the defer conn.Close(websocket.StatusInternalError, "...") lines? This pattern ensures the connection always gets closed, even if the function panics or errors out. The status and reason are sent to the peer, explaining why the connection is terminating.

When your application logic finishes normally, you should close with a normal status.

// In your handler, when you're done successfully
conn.Close(websocket.StatusNormalClosure, "")

// If the input is invalid, maybe use:
conn.Close(websocket.StatusInvalidPayload, "Expected JSON object")

These status codes (defined in the RFC) allow your client and server to understand the reason for closure, moving beyond just “it broke” to “it broke because of this.” It’s a small touch that makes debugging vastly easier.

nhooyr.io/websocket isn’t just a replacement for Gorilla; it’s a thoughtful evolution. It provides the right level of abstraction without hiding the protocol’s details, and its API is a masterclass in modern Go design. Use it. You’ll thank yourself later.