How to Use nhooyr/websocket (Modern WebSocket Library) in Go

Web
Use `nhooyr/websocket` by importing the package and calling `websocket.Dial` to establish a connection, then use the returned `Conn` object to read and write messages with automatic framing and compression handling.

The handshake that never ends

You are building a live dashboard that updates stock prices every second. Or a multiplayer game where player positions need to sync instantly. Or a chat application where messages arrive without the user refreshing the page. The standard HTTP request-response cycle breaks down here. HTTP is designed for a single question and a single answer. Once the response arrives, the connection closes. Waiting for the next update means polling the server repeatedly, burning bandwidth and adding latency.

WebSockets solve this by upgrading a single HTTP connection into a persistent, bidirectional pipe. The client sends an upgrade request. The server agrees. The protocol switches from text-based HTTP to a binary framing layer. Either side can send data at any time without waiting for a request. Go's standard library deliberately omits WebSocket support. The language designers prefer to leave protocol-specific networking to the community. That leaves you to pick a third-party package. nhooyr/websocket is the current standard because it treats the protocol as a solved problem and focuses on safe, idiomatic Go. It handles framing, compression, keep-alives, and error propagation so you can write application logic instead of parsing binary frames.

How the protocol actually works

Think of HTTP like sending postal mail. You write a letter, drop it in a box, and wait for a reply. WebSockets are like opening a dedicated phone line. The initial call uses the public telephone network (HTTP). Once both parties answer, they switch to a private line that stays open until one side hangs up. The protocol starts with an HTTP Upgrade header. The server responds with status code 101 Switching Protocols. After that handshake, the connection speaks a completely different language.

The WebSocket protocol wraps your data in frames. Each frame contains an opcode that tells the receiver whether the payload is text, binary, a ping, a pong, or a close signal. Frames can be fragmented if the message is large. The library handles all of this transparently. You pass a Go string or a byte slice to Write, and it builds the correct frame. You pass a pointer to a variable to Read, and it decodes the frame back into a Go value. Compression is negotiated during the handshake. The library tracks the state machine, validates frame boundaries, and enforces the spec. You never touch the raw bytes unless you explicitly want to.

Context propagation is the backbone of every operation. Every read, write, and dial call takes a context.Context as its first argument. This is a Go convention that applies to all long-lived I/O. The context carries deadlines, cancellation signals, and request-scoped values. When the context expires, the underlying network call aborts. The library respects this pattern strictly. If you pass a context without a timeout to a blocking read, your goroutine will hang until the connection drops. Always attach a deadline or a cancellation channel to your context.

The client side

Here is the simplest client that connects to a server, sends a message, and waits for a reply.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/nhooyr/websocket"
)

func main() {
	// Attach a deadline so the dial and read operations cannot block forever
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Initiate the HTTP upgrade and switch to the WebSocket protocol
	conn, resp, err := websocket.Dial(ctx, "ws://localhost:8080", nil)
	if err != nil {
		log.Fatalf("dial failed: %v", err)
	}
	defer conn.CloseNow()

	// Verify the server actually agreed to the protocol switch
	if resp.StatusCode != http.StatusSwitchingProtocols {
		log.Fatalf("upgrade rejected: %s", resp.Status)
	}

	// Encode the string into a text frame and send it over the wire
	if err := websocket.Write(ctx, conn, websocket.MessageText, []byte("hello")); err != nil {
		log.Fatalf("write failed: %v", err)
	}

	// Block until a frame arrives, decode it, and unmarshal into a string
	var msg string
	if _, err := websocket.Read(ctx, conn, &msg); err != nil {
		log.Fatalf("read failed: %v", err)
	}

	fmt.Println("Server replied:", msg)
}

The runtime flow follows a strict sequence. context.WithTimeout creates a cancellation signal that fires after five seconds. websocket.Dial opens a TCP connection, sends the HTTP upgrade request, and waits for the 101 response. If the server rejects the upgrade or the network drops, Dial returns an error. The resp object lets you inspect the HTTP status before proceeding. defer conn.CloseNow() ensures the connection tears down cleanly when main exits. websocket.Write takes the context, the connection, an opcode, and the payload. It fragments the payload if needed, applies compression if negotiated, and writes the frame. websocket.Read blocks until a frame arrives. It validates the opcode, reassembles fragments, decompresses if necessary, and unmarshals the payload into your pointer. The context deadline applies to both the dial and the read. If the server never replies, the context expires and Read returns a context.DeadlineExceeded error.

The server side

A server handler needs to accept the upgrade, manage the connection lifecycle, and handle concurrent messages safely. Here is a realistic echo handler that respects context cancellation and cleans up resources.

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/nhooyr/websocket"
)

func handler(w http.ResponseWriter, r *http.Request) {
	// Upgrade the incoming HTTP request to a WebSocket connection
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		log.Printf("accept failed: %v", err)
		return
	}
	defer conn.CloseNow()

	// Use the request context so cancellation propagates to reads and writes
	ctx := r.Context()

	// Loop until the context expires or the connection closes
	for {
		var msg string
		// Block until a text frame arrives or the context times out
		_, err := websocket.Read(ctx, conn, &msg)
		if err != nil {
			log.Printf("read ended: %v", err)
			return
		}

		// Echo the message back with the correct text opcode
		if err := websocket.Write(ctx, conn, websocket.MessageText, []byte(msg)); err != nil {
			log.Printf("write failed: %v", err)
			return
		}
	}
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The server runtime behaves differently from the client. websocket.Accept reads the upgrade request, validates the headers, and writes the 101 response. It returns a *websocket.Conn that wraps the underlying net.Conn. The request context carries the HTTP server's shutdown signals. When the HTTP server stops, the context cancels, and all blocking reads return immediately. The for loop processes messages sequentially. Each Read blocks until data arrives. If the client disconnects or the context expires, Read returns an error and the loop exits. defer conn.CloseNow() sends a close frame and tears down the TCP connection. The handler returns, and the HTTP server reclaims the goroutine. This pattern keeps one goroutine per connection, which matches the natural flow of WebSocket streams.

Where things go wrong

WebSocket code fails in predictable ways. The most common mistake is passing a context without a deadline to a blocking operation. If you use context.Background() for a long-lived read, the goroutine will hang indefinitely if the client disconnects without sending a close frame. The runtime will not panic. It will just sit there, consuming memory and file descriptors. Always attach a timeout or a cancellation channel.

Another frequent error is ignoring the return value of Read or Write. The compiler will reject unused variables with declared and not used if you assign the error to a variable and never check it. If you use the blank identifier to discard the error, you lose the ability to detect connection drops. The if err != nil { return err } pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Do not skip it.

Type mismatches cause silent data corruption. websocket.Read unmarshals based on the pointer type you pass. If you pass a *string but the server sends binary data, the library returns an error. If you pass a *[]byte but the server sends text, the library decodes the UTF-8 bytes into the slice. The compiler does not catch opcode mismatches at compile time. You get a runtime error like websocket: invalid opcode or websocket: unexpected close. Always verify the message type matches your application protocol.

Goroutine leaks happen when you spawn a background goroutine per connection but forget to cancel it when the connection drops. If a goroutine waits on a channel that never closes, it stays in the goroutine scheduler forever. The worst goroutine bug is the one that never logs. Always tie background goroutines to the connection's context. When the context cancels, the goroutine should exit cleanly.

The library also enforces strict framing rules. You cannot send a message larger than the configured limit without enabling fragmentation. If you exceed the limit, Write returns an error. The default limit is generous for most applications. If you need to stream large files, switch to binary opcodes and handle chunking manually.

When to reach for WebSockets

Use WebSockets when the server needs to push updates to the client without polling. Use HTTP long-polling when you need maximum compatibility with legacy proxies that block persistent connections. Use Server-Sent Events when you only need one-way communication from server to client. Use gRPC streams when you are building internal microservices that require strict typing and bidirectional streaming. Use plain HTTP when your data updates rarely and latency tolerance is high. Use WebSockets when you need low-latency, bidirectional communication and both sides support the protocol.

Where to go next