HowtoGo
Home / Tutorials / WebSockets
Tutorials

WebSockets

Implement the WebSocket protocol by hand in Go, with no third-party packages: the SHA-1 handshake, hijacking the connection away from net/http, and decoding the frames a browser sends.

This tutorial implements the WebSocket protocol by hand, with nothing but the standard library. No third-party package, no framework. You will write the handshake, take the raw socket away from net/http, and decode the bytes a browser sends.

By the end you will have an echo server a real browser can talk to, and you will know what the libraries that normally do this are actually doing.

Before you start
  • Go installed. go version should print something.
  • A browser. Its devtools console is the client, so nothing else to install.
  • Step 5 assumes you have seen bufio.Reader before. The TCP server tutorial covers it.

1 Set up the project

mkdir go-ws-tutorial
cd go-ws-tutorial
go mod init wstutorial

Create main.go. You will add to it in every step.

2 Answer the handshake

A WebSocket connection starts as an ordinary HTTP request carrying a random key. The server proves it understood by hashing that key with a fixed string from the spec and sending the result back.

package main

import (
    "crypto/sha1"
    "encoding/base64"
    "fmt"
)

// The magic string is fixed by RFC 6455. It never changes.
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

func acceptKey(clientKey string) string {
    h := sha1.New()
    h.Write([]byte(clientKey + wsGUID))
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

func main() {
    fmt.Println(acceptKey("dGhlIHNhbXBsZSBub25jZQ=="))
}
Run it
$ go run main.go
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

That is the exact value the specification gives as its worked example, so a match here means the handshake will be accepted by any client.

Three steps: take the client's key, append the magic string, SHA-1 the result and base64 it. Sending that back proves a WebSocket-aware server answered rather than a cache replaying the request.

3 Take the socket away from net/http

After the handshake, the bytes on the wire stop being HTTP. net/http has to let go of the connection entirely, which is what Hijack does. Replace main and add a handler.

func wsHandler(w http.ResponseWriter, r *http.Request) {
    if r.Header.Get("Upgrade") != "websocket" {
        http.Error(w, "expected websocket upgrade", http.StatusBadRequest)
        return
    }

    hj, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "hijacking not supported", http.StatusInternalServerError)
        return
    }
    conn, buf, err := hj.Hijack()
    if err != nil {
        return
    }
    defer conn.Close()

    key := r.Header.Get("Sec-WebSocket-Key")
    buf.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
        "Upgrade: websocket\r\n" +
        "Connection: Upgrade\r\n" +
        "Sec-WebSocket-Accept: " + acceptKey(key) + "\r\n\r\n")
    buf.Flush()

    log.Println("handshake complete with", conn.RemoteAddr())
}

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

Swap the "fmt" import for "log" and "net/http". Start the server, then open any page in your browser and paste this into the devtools console.

new WebSocket("ws://localhost:8080/ws")
Run it
$ go run main.go
2026/08/29 15:02:11 listening on :8080
2026/08/29 15:02:19 handshake complete with 127.0.0.1:55912

The browser reports the socket as open. Hijack returned the raw net.Conn plus a buffered reader and writer already wrapped around it, and from that point every byte is yours to write, the 101 response included.

The Upgrade header check keeps this handler from being reachable as a normal route by accident.

4 Read one message

A message arrives as two bytes of header, then four bytes of mask, then the text. The client scrambles its text with those four bytes on repeat, and running the same step again unscrambles it.

func readTextFrame(r *bufio.Reader) (string, error) {
    header := make([]byte, 2)
    if _, err := io.ReadFull(r, header); err != nil {
        return "", err
    }

    masked := header[1]&0x80 != 0
    payloadLen := int(header[1] & 0x7f)
    if payloadLen > 125 {
        return "", fmt.Errorf("message too long for this server: %d bytes", payloadLen)
    }

    var mask [4]byte
    if masked {
        if _, err := io.ReadFull(r, mask[:]); err != nil {
            return "", err
        }
    }

    payload := make([]byte, payloadLen)
    if _, err := io.ReadFull(r, payload); err != nil {
        return "", err
    }
    if masked {
        for i := range payload {
            payload[i] ^= mask[i%4]
        }
    }
    return string(payload), nil
}

Add "bufio", "fmt", and "io" to the imports, then call it where the log line sits in the handler.

    msg, err := readTextFrame(buf.Reader)
    if err != nil {
        log.Println("read:", err)
        return
    }
    log.Printf("got %q", msg)

Restart, then in the browser console:

const ws = new WebSocket("ws://localhost:8080/ws")
ws.onopen = () => ws.send("hello from the browser")
Run it
$ go run main.go
2026/08/29 15:14:03 listening on :8080
2026/08/29 15:14:09 got "hello from the browser"

The low seven bits of the second byte hold the length, which caps this reader at 125 bytes. A length of 126 means the real number is in the next two bytes, and 127 means the next eight, so those two values are rejected rather than misread.

5 Write a message back

Only the client scrambles. A server frame is a header and the text.

func writeTextFrame(w *bufio.Writer, msg string) error {
    if len(msg) > 125 {
        return fmt.Errorf("message too long: %d bytes", len(msg))
    }
    w.WriteByte(0x81)            // FIN set, opcode 1 for text
    w.WriteByte(byte(len(msg))) // mask bit stays 0
    _, err := w.WriteString(msg)
    return err
}

Replace the log.Printf in the handler with a reply.

    if err := writeTextFrame(buf.Writer, "echo: "+msg); err != nil {
        return
    }
    buf.Flush()

Restart, then in the browser console:

const ws = new WebSocket("ws://localhost:8080/ws")
ws.onmessage = e => console.log(e.data)
ws.onopen = () => ws.send("hello")
Run it
$ # in the browser console
echo: hello

0x81 is two fields packed into one byte: the top bit says this is the last frame of the message, and the low four bits say the payload is text. Forgetting Flush leaves the reply sitting in the buffer, which looks to the browser like a server that never answered.

6 Keep the connection open

One message per connection is not much of a socket. Wrap the read and write in a loop.

    for {
        msg, err := readTextFrame(buf.Reader)
        if err != nil {
            log.Println("read:", err)
            return
        }
        if err := writeTextFrame(buf.Writer, "echo: "+msg); err != nil {
            return
        }
        buf.Flush()
    }
Run it
$ # in the browser console, with ws still open
$ ws.send("one"); ws.send("two")
echo: one
echo: two

The deferred conn.Close from step 3 runs whichever way the loop exits, so a client that disappears does not leak the connection.

7 The finished program

main.go 88 lines Show
package main

import (
    "bufio"
    "crypto/sha1"
    "encoding/base64"
    "fmt"
    "io"
    "log"
    "net/http"
)

const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

func acceptKey(clientKey string) string {
    h := sha1.New()
    h.Write([]byte(clientKey + wsGUID))
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

func readTextFrame(r *bufio.Reader) (string, error) {
    header := make([]byte, 2)
    if _, err := io.ReadFull(r, header); err != nil {
        return "", err
    }

    masked := header[1]&0x80 != 0
    payloadLen := int(header[1] & 0x7f)
    if payloadLen > 125 {
        return "", fmt.Errorf("message too long for this server: %d bytes", payloadLen)
    }

    var mask [4]byte
    if masked {
        if _, err := io.ReadFull(r, mask[:]); err != nil {
            return "", err
        }
    }

    payload := make([]byte, payloadLen)
    if _, err := io.ReadFull(r, payload); err != nil {
        return "", err
    }
    if masked {
        for i := range payload {
            payload[i] ^= mask[i%4]
        }
    }
    return string(payload), nil
}

func writeTextFrame(w *bufio.Writer, msg string) error {
    if len(msg) > 125 {
        return fmt.Errorf("message too long: %d bytes", len(msg))
    }
    w.WriteByte(0x81)
    w.WriteByte(byte(len(msg)))
    _, err := w.WriteString(msg)
    return err
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    if r.Header.Get("Upgrade") != "websocket" {
        http.Error(w, "expected websocket upgrade", http.StatusBadRequest)
        return
    }
    hj, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "hijacking not supported", http.StatusInternalServerError)
        return
    }
    conn, buf, err := hj.Hijack()
    if err != nil {
        return
    }
    defer conn.Close()

    key := r.Header.Get("Sec-WebSocket-Key")
    buf.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
        "Upgrade: websocket\r\n" +
        "Connection: Upgrade\r\n" +
        "Sec-WebSocket-Accept: " + acceptKey(key) + "\r\n\r\n")
    buf.Flush()

    for {
        msg, err := readTextFrame(buf.Reader)
        if err != nil {
            log.Println("read:", err)
            return
        }
        if err := writeTextFrame(buf.Writer, "echo: "+msg); err != nil {
            return
        }
        buf.Flush()
    }
}

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

What this leaves out

Enough to echo a short message. A production server needs three more things.

  • Long messages. Anything over 125 bytes stores its length differently, and a very long message arrives split across several frames that have to be joined back together.
  • Saying goodbye. When a client asks to close, the server should answer and then hang up. This one drops the connection, which looks like a crash to the other side.
  • Ping and pong. Idle connections are kept alive by control frames this reader treats as text and mangles.

What you built

You have a WebSocket server a browser can hold a conversation with, written against nothing but the standard library. It completes the handshake, takes the socket over, decodes masked client frames, and answers with frames of its own.

Four pieces did that work. crypto/sha1 and encoding/base64 proved the handshake, http.Hijacker handed over the raw connection, io.ReadFull read exact byte counts off it, and a XOR loop undid the client's mask. Every WebSocket library for Go is this, plus the parts listed above.