Right, so you want real-time communication. You’ve tried long-polling and it felt like a hack from 2005. You’ve heard about Server-Sent Events (SSE) and they’re cool, but they’re a one-way street. You need a proper, full-duplex, real-time channel. That’s where WebSockets come in, and the first thing you need to understand is that it all starts with a slightly awkward handshake—an HTTP upgrade.

Think of it like this: HTTP is a polite, transactional conversation. “I ask, you answer, we’re done.” WebSockets want to turn that into a pub where you can just shout updates at each other continuously. But you can’t just barge into the pub yelling; you have to ask the bouncer (the server) nicely to change venues. This asking is the HTTP Upgrade request.

The Upgrade Request: A Polite, Calculated Negotiation

The client kicks this off by sending a perfectly normal-looking HTTP request, but with some very specific headers that say, “Hey, I know we’re doing HTTP, but can we switch protocols?” It’s the networking equivalent of saying, “I know we came here for coffee, but wanna go get a beer instead?”

Here’s what that request looks like. The magic is in the headers:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com

Let’s break down the key players:

  • Upgrade: websocket and Connection: Upgrade: This is the core ask. “Please upgrade this connection to a WebSocket.”
  • Sec-WebSocket-Key: A random, 16-byte value base64-encoded. This isn’t a secret key; it’s a tool for the server to prove it’s actually paying attention. The server will take this key, mash it together with a magic UUID string (“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”), hash it with SHA-1, and send the base64-encoded result back. This proves the server actually understands the WebSocket protocol and isn’t just blindly echoing your headers. It’s a way to avoid caching intermediaries accidentally creating “WebSocket” connections. Pretty clever, actually.
  • Sec-WebSocket-Version: 13. Just specify 13. The earlier versions were a mess and you don’t want them. This is non-negotiable.

The Server’s Response: Sealing the Deal

The server’s job is to validate this request. Is the path correct? Is the origin allowed? Is the version 13? If everything checks out, it responds with a 101 Switching Protocols status code. Any other status code (200, 404, etc.) means the upgrade failed and the body is just a regular HTTP error message.

A successful response looks like this:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The Sec-WebSocket-Accept is the crucial part. The server generates this by concatenating your Sec-WebSocket-Key with that magic UUID string, hashing it with SHA-1, and encoding the result in base64. If the client doesn’t get back the exact value it calculated, it must fail the connection. This is your first line of defense against misconfigured proxies.

Here’s how you’d handle this in Go. Notice we use the Hijacker interface—this is critical.

package main

import (
    "crypto/sha1"
    "encoding/base64"
    "fmt"
    "net/http"
    "strings"
)

// The universally unique magic string for WebSocket handshakes, as defined in RFC 6455.
const websocketMagicString = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

func handleUpgrade(w http.ResponseWriter, r *http.Request) {
    // 1. Validate the request method. It MUST be a GET.
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // 2. Check that the required headers are present and correct.
    if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
        http.Error(w, "Bad Request: Upgrade header missing or invalid", http.StatusBadRequest)
        return
    }
    if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
        http.Error(w, "Bad Request: Connection header missing or invalid", http.StatusBadRequest)
        return
    }
    if r.Header.Get("Sec-WebSocket-Key") == "" {
        http.Error(w, "Bad Request: Sec-WebSocket-Key header missing", http.StatusBadRequest)
        return
    }
    if r.Header.Get("Sec-WebSocket-Version") != "13" {
        http.Error(w, "WebSocket protocol version 13 required", http.StatusBadRequest)
        return
    }

    // 3. You should also check the Origin header here for security in production!
    // origin := r.Header.Get("Origin")
    // if !isAllowedOrigin(origin) { ... }

    // 4. Calculate the accept key.
    clientKey := r.Header.Get("Sec-WebSocket-Key")
    acceptKey := computeAcceptKey(clientKey)

    // 5. Hijack the underlying TCP connection from the HTTP server.
    // This gives us direct, low-level control.
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
        return
    }
    conn, bufrw, err := hijacker.Hijack()
    if err != nil {
        http.Error(w, "Could not hijack connection", http.StatusInternalServerError)
        return
    }
    defer conn.Close()

    // 6. Write the acceptance response directly to the TCP connection.
    response := fmt.Sprintf(
        "HTTP/1.1 101 Switching Protocols\r\n"+
            "Upgrade: websocket\r\n"+
            "Connection: Upgrade\r\n"+
            "Sec-WebSocket-Accept: %s\r\n\r\n",
        acceptKey,
    )
    if _, err := bufrw.WriteString(response); err != nil {
        return // Handle error
    }
    if err := bufrw.Flush(); err != nil {
        return // Handle error
    }

    // 7. Congratulations! The connection is now upgraded.
    // 'conn' is now a raw net.Conn ready for WebSocket data frames.
    fmt.Fprintf(conn, "You are now connected via WebSocket!")
    // From here, you'd implement the WebSocket frame protocol.
}

func computeAcceptKey(clientKey string) string {
    hash := sha1.Sum([]byte(clientKey + websocketMagicString))
    return base64.StdEncoding.EncodeToString(hash[:])
}

Why Hijacking? Taking Control from the HTTP Server

This is the most important Go-specific concept here. The standard http.Handler is designed for the request/response cycle. It expects to write headers and a body and then be done, automatically closing the connection. For a persistent WebSocket, that’s a disaster.

http.Hijacker lets you reach underneath the HTTP abstraction and grab the raw net.Conn and its bufio.ReadWriter. Once you call Hijack(), you are entirely responsible for managing that connection. The HTTP server washes its hands of it. You must close it. You must read from and write to it using the WebSocket framing protocol. This is the moment you jump from the comfortable world of HTTP handlers into the bare-metal world of persistent network programming.

Common Pitfalls and the Ghost of Proxies Past

  1. Not Checking the Connection Header Correctly: The Connection header is a hop-by-hop header and can have multiple values (e.g., Connection: keep-alive, Upgrade). A naive == "Upgrade" check will fail. You need to split by commas, trim spaces, and check for the case-insensitive presence of “upgrade”.
  2. Forgetting to Handle the Hijack Error: Hijack() can fail, especially if you’ve already written to the response body. Always check the ok boolean and the err.
  3. The Proxy Menace: This handshake is designed to avoid issues with dumb intermediaries, but smart proxies (especially HTTP-configured ones) can still mess it up. They might see an HTTP GET, cache the 101 response, and serve that cached “upgrade” to the next poor client. This is why the Sec-WebSocket-Key challenge/response is so important—it’s unique per connection.
  4. Not Using a Library: You just did this by hand to understand it. Now, for the love of all that is holy, use a production-grade library like github.com/gorilla/websocket or nhooyr.io/websocket. They have handled every edge case, performance optimization, and security nuance you haven’t even thought of yet. Writing a correct and secure WebSocket frame parser is a non-trivial task. Use the tools.