ZetCode

Go Gorilla WebSocket

last modified October 30, 2026

In this article we show how to work with WebSockets in Go using the Gorilla WebSocket package. WebSocket is a protocol that provides full-duplex communication over a single TCP connection; it is used in chat applications, games and live dashboards.

The examples use gorilla/websocket v1.5.3 and were run with Go 1.27.0. The clients are Go programs so that every response is reproducible; the last example connects from a browser and was checked with headless Chrome.

The WebSocket server

The server upgrades an HTTP request to a WebSocket connection with Upgrader.Upgrade and then reads and writes messages in a loop. The program has five endpoints: /echo returns every message unchanged, /json decodes and encodes JSON, /limit restricts the message size, /ping answers ping frames, and /origin accepts only one origin.

main.go
package main

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

    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

type Message struct {
    Name string `json:"name"`
}

var strict = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return r.Header.Get("Origin") == "http://localhost:8080"
    },
}

func main() {

    http.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {

        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            log.Println("upgrade:", err)
            return
        }
        defer conn.Close()

        for {
            mt, msg, err := conn.ReadMessage()
            if err != nil {
                return
            }

            log.Printf("received %d bytes", len(msg))

            if err := conn.WriteMessage(mt, msg); err != nil {
                return
            }
        }
    })

    http.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) {

        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            log.Println("upgrade:", err)
            return
        }
        defer conn.Close()

        var msg Message
        if err := conn.ReadJSON(&msg); err != nil {
            log.Println("read:", err)
            return
        }

        conn.WriteJSON(Message{Name: "Hello " + msg.Name})
    })

    http.HandleFunc("/limit", func(w http.ResponseWriter, r *http.Request) {

        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            log.Println("upgrade:", err)
            return
        }
        defer conn.Close()

        conn.SetReadLimit(16)

        if _, _, err := conn.ReadMessage(); err != nil {
            log.Println("read:", err)
            conn.WriteMessage(websocket.CloseMessage,
                websocket.FormatCloseMessage(websocket.CloseMessageTooBig, "limit"))
        }
    })

    http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {

        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            log.Println("upgrade:", err)
            return
        }
        defer conn.Close()

        conn.SetPingHandler(func(data string) error {

            err := conn.WriteControl(websocket.PongMessage, []byte(data),
                time.Now().Add(time.Second))
            if err != nil {
                return err
            }

            return conn.WriteMessage(websocket.TextMessage, []byte("got ping"))
        })

        for {
            if _, _, err := conn.ReadMessage(); err != nil {
                return
            }
        }
    })

    http.HandleFunc("/origin", func(w http.ResponseWriter, r *http.Request) {

        conn, err := strict.Upgrade(w, r, nil)
        if err != nil {
            log.Println("upgrade:", err)
            return
        }
        defer conn.Close()

        conn.WriteMessage(websocket.TextMessage, []byte("origin accepted"))
    })

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "index.html")
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The package is installed with go get github.com/gorilla/websocket. Upgrade takes over the underlying connection and returns a *websocket.Conn. Reading and writing on one connection must not happen concurrently, except for one reader and one writer.

$ go run main.go
Listening on 127.0.0.1:8080

Sending and receiving text messages

The client dials the endpoint with websocket.DefaultDialer.Dial and sends a text frame with WriteMessage. ReadMessage returns the message type and the payload of the next data frame.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/echo", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    err = conn.WriteMessage(websocket.TextMessage, []byte("hello server"))
    if err != nil {
        log.Fatal(err)
    }

    mt, msg, err := conn.ReadMessage()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("type:", mt)
    fmt.Println("body:", string(msg))
}

TextMessage has the value 1 and BinaryMessage has the value 2, which is what the numeric type shows. The echo server sends the same frame back, so the payload is unchanged.

$ go run main.go
type: 1
body: hello server

Binary messages

Binary frames carry arbitrary bytes, such as images or serialized data. The only difference on the client is the message type passed to WriteMessage.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/echo", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    data := []byte{0x01, 0x02, 0x03, 0xff}

    err = conn.WriteMessage(websocket.BinaryMessage, data)
    if err != nil {
        log.Fatal(err)
    }

    mt, msg, err := conn.ReadMessage()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("type:", mt)
    fmt.Println("body:", msg)
}

The bytes are printed as a slice of decimal numbers, and the message type is 2. The server does not interpret the payload, so it can echo any binary content.

$ go run main.go
type: 2
body: [1 2 3 255]

JSON messages

WriteJSON and ReadJSON marshal and unmarshal a value with encoding/json and send it as a text frame. The server decodes a Message and replies with a greeting.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

type Message struct {
    Name string `json:"name"`
}

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/json", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    if err := conn.WriteJSON(Message{Name: "John"}); err != nil {
        log.Fatal(err)
    }

    var reply Message

    if err := conn.ReadJSON(&reply); err != nil {
        log.Fatal(err)
    }

    fmt.Println("reply:", reply.Name)
}

The struct tags determine the JSON keys, so the frame contains {"name":"John"} and the reply contains {"name":"Hello John"}. The client and the server have to agree on the message schema.

$ go run main.go
reply: Hello John

Limiting the message size

SetReadLimit caps the size of a single message. When a client sends more, the connection is closed with code 1009 and the read returns a *websocket.CloseError.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/limit", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    msg := []byte("this message is longer than sixteen bytes")

    if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
        log.Fatal(err)
    }

    _, _, err = conn.ReadMessage()
    fmt.Println("read error:", err)
}

The server accepts at most 16 bytes on this endpoint, so the 42-byte message is rejected. The close code 1009 means "message too big"; the text of the close frame is not included in the client's error.

$ go run main.go
read error: websocket: close 1009 (message too big)

Ping and pong

Ping and pong are control frames. Gorilla handles them inside ReadMessage, so a ping does not appear as a normal message. The server installs a custom PingHandler that sends a pong and a text reply.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/ping", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
        log.Fatal(err)
    }

    mt, msg, err := conn.ReadMessage()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("type:", mt)
    fmt.Println("body:", string(msg))
}

The pong frame is consumed by the client's read loop, and the next data frame is the text reply from the server. In a long-lived connection the same mechanism is used with read deadlines to detect a peer that stopped responding.

$ go run main.go
type: 1
body: got ping

Checking the origin

Browsers send an Origin header with the handshake, and a server should reject connections from pages it does not trust. The CheckOrigin function in the upgrader does this; a rejected handshake is answered with 403.

main.go
package main

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

    "github.com/gorilla/websocket"
)

func main() {

    header := http.Header{}
    header.Set("Origin", "http://evil.example")

    conn, resp, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/origin", header)
    if err != nil {
        fmt.Println("status:", resp.Status)
        fmt.Println("error:", err)
        return
    }

    defer conn.Close()

    _, msg, err := conn.ReadMessage()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(string(msg))
}

The Dial function returns the HTTP response even when the handshake fails, which makes the status available. The server accepts only http://localhost:8080, so this origin is refused.

$ go run main.go
status: 403 Forbidden
error: websocket: bad handshake

Closing the connection

A client closes a connection with a CloseMessage that carries a status code and a reason. The peer answers with a close frame, and the next read returns a *websocket.CloseError with the code.

main.go
package main

import (
    "fmt"
    "log"

    "github.com/gorilla/websocket"
)

func main() {

    conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/echo", nil)
    if err != nil {
        log.Fatal(err)
    }

    defer conn.Close()

    err = conn.WriteMessage(websocket.CloseMessage,
        websocket.FormatCloseMessage(websocket.CloseNormalClosure, "bye"))
    if err != nil {
        log.Fatal(err)
    }

    _, _, err = conn.ReadMessage()
    fmt.Println("read error:", err)
}

CloseNormalClosure is the code 1000. The read returns an error, as it does after any close, and the program prints it instead of treating it as a failure.

$ go run main.go
read error: websocket: close 1000 (normal)

Connecting from a browser

The server also serves a small page at /. The page opens a connection to /echo, sends the value of the input field when the connection is ready, and appends every reply to a pre element. The template literal uses ${e.data}; without the dollar sign the page would print the text {e.data} instead of the message.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket echo</title>
</head>
<body>

    <input id="input" type="text" value="hello from the browser">
    <button onclick="send()">Send</button>
    <pre id="output"></pre>

    <script>
        const input = document.getElementById("input");
        const output = document.getElementById("output");

        const socket = new WebSocket("ws://127.0.0.1:8080/echo");

        socket.onopen = () => {
            output.textContent += "connected\n";
            socket.send(input.value);
        };

        socket.onmessage = (e) => {
            output.textContent += `echo: ${e.data}\n`;
        };

        function send() {
            socket.send(input.value);
            input.value = "";
        }
    </script>
</body>
</html>

The browser connects to the same /echo endpoint as the Go clients. The text below the form is what the page showed after loading in headless Chrome.

connected
echo: hello from the browser

Source

Gorilla WebSocket - Github page
Gorilla WebSocket - package reference

In this article we have worked with WebSockets in Go: the echo server, text and binary messages, JSON, read limits, ping and pong, origin checks, closing a connection, and a browser client.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.