WebSockets in Go

Enable WebSockets in Go by using the http.Hijacker interface to take control of the underlying network connection.

The persistent pipe

You build a dashboard that needs live market data. You build a multiplayer game where player positions update every frame. You build a chat room where messages arrive instantly. The standard HTTP request-response cycle feels like shouting across a canyon and waiting for an echo. You need a persistent, bidirectional pipe that stays open until one side decides to hang up. That is what WebSockets provide.

Go does not ship a full WebSocket implementation in the standard library. Instead, it gives you the exact tool you need to take over the connection yourself. The standard library treats WebSockets as a protocol upgrade. The server receives a normal HTTP request, agrees to switch protocols, tears down the HTTP layer, and hands you a raw TCP socket. From that moment forward, both the client and server can send data at any time, without waiting for a request.

The mechanism is called hijacking. You assert that the http.ResponseWriter implements http.Hijacker, call Hijack(), and suddenly you own the underlying net.Conn. The HTTP middleware chain stops. You are responsible for reading bytes, writing bytes, and closing the connection when you are done.

How the upgrade actually works

A WebSocket connection begins as a standard HTTP request. The browser sends an Upgrade: websocket header along with a Sec-WebSocket-Key. The server validates the key, computes a SHA-1 hash, base64 encodes it, and sends back a 101 Switching Protocols response with a matching Sec-WebSocket-Accept header. Once that response flushes, the HTTP conversation ends. The TCP connection remains open, but the protocol switches to WebSocket framing.

WebSocket frames are not plain text. Each frame contains a two-byte header that specifies the operation code, payload length, and masking key. Browsers mask outgoing frames to prevent proxy caching attacks. Servers must unmask incoming frames and never mask outgoing ones. Parsing this manually is tedious and error-prone. The standard library leaves frame parsing to you or to third-party packages, but the hijack pattern remains identical regardless of which library you choose.

Think of hijacking like taking the steering wheel from a self-driving car. The car was following HTTP rules, routing to handlers, and managing request lifecycles. You pull the wheel, the dashboard switches to manual mode, and you now control the engine directly. You get full control, but you also lose the safety nets.

The minimal hijack pattern

Here is the simplest way to seize a connection and complete the protocol upgrade.

// handleWebSocket upgrades an HTTP request to a raw TCP connection.
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    // Assert the ResponseWriter implements http.Hijacker.
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        // Fail fast if the server does not support hijacking.
        http.Error(w, "hijacking not supported", http.StatusInternalServerError)
        return
    }

    // Hijack extracts the underlying net.Conn and buffered I/O.
    conn, bufrw, err := hijacker.Hijack()
    if err != nil {
        fmt.Printf("hijack failed: %v\n", err)
        return
    }
    // Ensure the raw socket closes when the handler exits.
    defer conn.Close()

    // Send the 101 status code to complete the protocol upgrade.
    fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\n\r\n")
    bufrw.Flush()
}

The type assertion checks whether the server supports hijacking. Most standard http.Server implementations do, but some reverse proxies or custom ResponseWriter wrappers strip the interface. If the assertion fails, ok becomes false. You return a standard HTTP error instead of panicking.

Calling Hijack() returns three values: the raw net.Conn, a *bufio.ReadWriter that wraps the connection, and an error. The buffered writer is useful for sending the upgrade response efficiently. Once you flush the 101 response, the HTTP layer is gone. You are now reading and writing raw bytes over TCP.

The defer conn.Close() call is mandatory. If you forget it, the operating system keeps the file descriptor open until the process exits. File descriptors are finite. Leak them enough and your server refuses new connections entirely.

What happens under the hood

When you call Hijack(), the net/http package detaches the connection from its internal goroutine pool. The server stops tracking the request lifecycle. It will not call w.WriteHeader() or w.Write() for you. It will not parse form data. It will not handle timeouts. You are now the server.

The net.Conn implements io.Reader and io.Writer. You can pass it to any function that expects a stream. This means you can wrap it in a scanner, pipe it to a compressor, or feed it directly into a WebSocket frame parser. The connection behaves exactly like a dialled TCP socket, except it was handed to you instead of created by you.

Because the HTTP server no longer manages the connection, standard timeouts like ReadTimeout and WriteTimeout on http.Server stop applying. You must implement your own deadlines if you want to prevent slow clients from holding connections open forever. The net.Conn interface provides SetReadDeadline() and SetWriteDeadline() for this exact purpose.

Hijacking is a deliberate escape hatch. Go prefers explicit control over hidden magic. You get the raw socket because you asked for it, and you accept the responsibility that comes with it.

A realistic concurrency pattern

Production WebSocket handlers rarely run in a single goroutine. You typically split reading and writing into separate goroutines to avoid blocking. One goroutine reads frames from the client, another sends frames to the client, and a third coordinates shutdown. Context cancellation keeps everything clean.

Here is the read side of that pattern.

// readLoop consumes WebSocket frames until cancellation or error.
func readLoop(ctx context.Context, conn net.Conn, msgCh chan []byte) {
    // Set a read deadline to prevent indefinite blocking.
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))
    defer close(msgCh)

    buf := make([]byte, 4096)
    for {
        // Check context before each read to respect cancellation.
        select {
        case <-ctx.Done():
            return
        default:
        }

        n, err := conn.Read(buf)
        if err != nil {
            // Context deadline exceeded or client disconnect.
            return
        }
        // Copy the slice to avoid buffer reuse issues.
        msg := make([]byte, n)
        copy(msg, buf[:n])
        msgCh <- msg
    }
}

The context parameter follows Go convention: it is always the first argument, conventionally named ctx. Functions that accept a context should check it before blocking operations. The select statement ensures the goroutine exits promptly when the parent context cancels.

The defer close(msgCh) call signals to the writer goroutine that no more messages will arrive. Closing a channel is a broadcast mechanism. Any goroutine reading from it will receive zero values and can break out of its loop. This pattern prevents goroutine leaks when the client disconnects abruptly.

Here is the write side.

// writeLoop dispatches messages to the client until shutdown.
func writeLoop(ctx context.Context, conn net.Conn, msgCh <-chan []byte) {
    for {
        // Block until a message arrives or context cancels.
        select {
        case <-ctx.Done():
            return
        case msg, ok := <-msgCh:
            if !ok {
                // Channel closed by readLoop. Exit cleanly.
                return
            }
            // Set a write deadline to avoid blocking on a full socket.
            conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
            _, err := conn.Write(msg)
            if err != nil {
                return
            }
        }
    }
}

The write loop uses a unidirectional channel <-chan []byte to enforce flow control. The read goroutine pushes, the write goroutine consumes. If the write channel buffers, you risk memory growth. If it is unbuffered, you risk blocking the read loop. A small buffer of 16 or 32 usually strikes the right balance for chat or telemetry streams.

The SetWriteDeadline() call prevents the goroutine from hanging when the client's TCP receive buffer fills up. Without it, a slow client can freeze your entire write pipeline. Deadlines turn indefinite blocks into recoverable errors.

Run both loops in separate goroutines, pass them a shared context, and cancel the context when you want to shut down. The worst goroutine bug is the one that never logs. Always attach a logger or metrics counter to your read and write loops so you can track connection lifecycles.

Where things break

Hijacked connections expose you to raw network behavior. The compiler will not save you from logical mistakes. You will run into runtime issues instead.

If you forget to check the ok value from the type assertion, the program panics with interface conversion: http.ResponseWriter is not http.Hijacker: missing method Hijack. The compiler allows the assertion because http.ResponseWriter is an interface, and Go does not verify interface satisfaction at compile time for type assertions. Always use the comma-ok idiom.

If you pass a hijacked connection to a function that expects an http.ResponseWriter, you get cannot use conn (variable of type net.Conn) as http.ResponseWriter value in argument. The types are incompatible by design. You cannot mix HTTP and raw socket APIs on the same connection.

Goroutine leaks are the most common runtime failure. A read loop that blocks on conn.Read() will never return if the client disconnects without sending a TCP FIN. The connection stays open, the goroutine stays alive, and memory grows. Always set read deadlines or use a context with a timeout. The net.Conn interface does not guarantee that Read() returns an error on abrupt disconnects across all operating systems. Deadlines force the issue.

Another subtle trap is buffer reuse. If you pass a slice directly from conn.Read() into a channel without copying it, the next read will overwrite the same underlying array. The channel will deliver corrupted data. Always allocate a new slice or copy the bytes before sending.

Error handling follows Go convention: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors in a hijack handler. Log them, close the connection, and exit the goroutine.

Picking the right transport

WebSockets are powerful, but they are not the default answer for every real-time problem. Choose the transport that matches your data flow and infrastructure.

Use a raw hijacker when you are building a custom protocol parser or need absolute control over the TCP stream. Use a third-party WebSocket library when you want frame masking, ping/pong keepalives, and safe concurrent read/write helpers without reinventing the spec. Use HTTP long-polling when your infrastructure sits behind legacy reverse proxies that drop persistent connections or when you need simple firewall compatibility. Use Server-Sent Events when you only need server-to-client streaming and want to keep the HTTP layer intact for caching and compression. Use plain sequential HTTP when your data updates infrequently and polling every few seconds is acceptable.

The simplest thing that works is usually the right thing. Do not reach for WebSockets just because they feel modern. Reach for them when you genuinely need bidirectional, low-latency communication and your infrastructure can support persistent connections.

Where to go next