Go WebSocket with gorilla/websocket
The gorilla/websocket package is the most widely used WebSocket library in the Go ecosystem. This golang websocket library implements the WebSocket protocol (RFC 6455) on top of Go’s net/http server, giving you full control over connection upgrades, message framing, and close handshakes. If you need a WebSocket server or client in Go without pulling in a full framework, gorilla websocket is the standard choice.
Go’s concurrency model, with goroutines and channels, is a natural fit for WebSocket applications. Each connection can be handled in its own goroutine, and you can coordinate broadcasts and room management through channels. The gorilla/websocket package stays close to the protocol while handling the low-level details of frame parsing, UTF-8 validation, and control messages.
Installation
Initialize a Go module and add gorilla/websocket:
mkdir websocket-demo && cd websocket-demo
go mod init websocket-demo
go get github.com/gorilla/websocket
At the time of writing, the latest release is v1.5.x. The package requires Go 1.20 or later, though you should use the latest stable Go release for security patches and performance improvements.
You can verify the dependency was added:
go list -m github.com/gorilla/websocket
The package has zero external dependencies, which keeps your binary small and your dependency tree clean.
Basic Server (Echo)
A WebSocket server in Go starts as a regular HTTP server. You register an HTTP handler that upgrades the connection from HTTP to WebSocket using the Upgrader struct.
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func echoHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
defer conn.Close()
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Println("Read error:", err)
break
}
fmt.Printf("Received: %s\n", message)
if err := conn.WriteMessage(messageType, message); err != nil {
log.Println("Write error:", err)
break
}
}
}
func main() {
http.HandleFunc("/ws", echoHandler)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
The Upgrader.Upgrade method performs the HTTP upgrade handshake and returns a *websocket.Conn. From there, you read and write messages in a loop. The messageType parameter is either websocket.TextMessage or websocket.BinaryMessage, and you should echo it back to preserve the frame type.
To test the server, you can open a browser console and connect:
const socket = new WebSocket('ws://localhost:8080/ws');
socket.onmessage = (event) => console.log(event.data);
socket.onopen = () => socket.send('hello from browser');
You can also test with the WebSocket Tester on tests.ws for a quick interactive check.
Origin Checking
By default, the Upgrader rejects cross-origin requests. During development, you may want to accept all origins:
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
In production, always validate the origin header against a list of allowed domains. Skipping origin checks opens you up to cross-site WebSocket hijacking. See WebSocket Security for more on this topic.
Basic Client
gorilla/websocket also provides a client-side dialer. This is useful for service-to-service communication, testing, or building CLI tools that connect to WebSocket endpoints.
package main
import (
"fmt"
"log"
"os"
"os/signal"
"github.com/gorilla/websocket"
)
func main() {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", nil)
if err != nil {
log.Fatal("Dial error:", err)
}
defer conn.Close()
done := make(chan struct{})
// Read messages in a separate goroutine
go func() {
defer close(done)
for {
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Read error:", err)
return
}
fmt.Printf("Received: %s\n", message)
}
}()
// Send a message
err = conn.WriteMessage(websocket.TextMessage, []byte("hello from Go client"))
if err != nil {
log.Println("Write error:", err)
return
}
// Wait for interrupt or connection close
select {
case <-done:
case <-interrupt:
log.Println("Interrupted, closing connection")
err := conn.WriteMessage(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
)
if err != nil {
log.Println("Close error:", err)
}
}
}
The DefaultDialer.Dial function returns a *websocket.Conn that has the same API as the server-side connection. You handle reads in a goroutine and writes from the main goroutine. This pattern keeps things simple and avoids concurrent write issues, since gorilla/websocket connections are not safe for concurrent writes.
Custom Dial Headers
If your server requires authentication or custom headers during the upgrade:
header := http.Header{}
header.Add("Authorization", "Bearer your-token-here")
conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", header)
The second argument to Dial lets you pass any HTTP headers that will be sent with the upgrade request.
JSON Messaging
Most real applications exchange structured data rather than plain strings. gorilla/websocket provides ReadJSON and WriteJSON methods that handle serialization for you.
type ChatMessage struct {
Username string `json:"username"`
Text string `json:"text"`
Time int64 `json:"time"`
}
func chatHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
defer conn.Close()
for {
var msg ChatMessage
if err := conn.ReadJSON(&msg); err != nil {
log.Println("ReadJSON error:", err)
break
}
fmt.Printf("%s: %s\n", msg.Username, msg.Text)
response := ChatMessage{
Username: "server",
Text: "Got your message: " + msg.Text,
Time: msg.Time,
}
if err := conn.WriteJSON(response); err != nil {
log.Println("WriteJSON error:", err)
break
}
}
}
Under the hood, ReadJSON reads a text message and calls json.Unmarshal, while WriteJSON calls json.Marshal and sends the result as a text frame. If you need more control over serialization (for example, using a different encoder or compressing the payload), read the raw bytes with ReadMessage and handle encoding yourself.
Broadcasting to Multiple Clients
A single echo server is useful for testing, but real applications need to send messages to multiple connected clients at once. The standard pattern in Go is a “hub” that manages a set of connections and dispatches messages through channels.
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
}
func newHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
}
}
func (h *Hub) run() {
for {
select {
case conn := <-h.register:
h.clients[conn] = true
log.Printf("Client connected. Total: %d", len(h.clients))
case conn := <-h.unregister:
if _, ok := h.clients[conn]; ok {
delete(h.clients, conn)
conn.Close()
log.Printf("Client disconnected. Total: %d", len(h.clients))
}
case message := <-h.broadcast:
for conn := range h.clients {
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
log.Println("Broadcast write error:", err)
conn.Close()
delete(h.clients, conn)
}
}
}
}
}
func (h *Hub) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
h.register <- conn
defer func() {
h.unregister <- conn
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
h.broadcast <- message
}
}
func main() {
hub := newHub()
go hub.run()
http.HandleFunc("/ws", hub.handleWebSocket)
log.Println("Broadcast server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
The hub runs in its own goroutine and is the only place where the clients map is accessed. This avoids any need for mutexes. Registration, unregistration, and broadcasting all happen through channels, which makes the design safe for concurrent use. Every connected client has a read loop in its own goroutine that forwards received messages to the hub’s broadcast channel.
Rooms and Groups
You can extend the hub pattern to support named rooms. Clients join a room, and broadcasts only go to members of that room.
type Room struct {
name string
clients map[*websocket.Conn]bool
}
type RoomHub struct {
rooms map[string]*Room
join chan JoinRequest
leave chan LeaveRequest
broadcast chan RoomMessage
}
type JoinRequest struct {
RoomName string
Conn *websocket.Conn
}
type LeaveRequest struct {
RoomName string
Conn *websocket.Conn
}
type RoomMessage struct {
RoomName string
Data []byte
}
func newRoomHub() *RoomHub {
return &RoomHub{
rooms: make(map[string]*Room),
join: make(chan JoinRequest),
leave: make(chan LeaveRequest),
broadcast: make(chan RoomMessage),
}
}
func (rh *RoomHub) run() {
for {
select {
case req := <-rh.join:
room, exists := rh.rooms[req.RoomName]
if !exists {
room = &Room{
name: req.RoomName,
clients: make(map[*websocket.Conn]bool),
}
rh.rooms[req.RoomName] = room
}
room.clients[req.Conn] = true
case req := <-rh.leave:
if room, exists := rh.rooms[req.RoomName]; exists {
delete(room.clients, req.Conn)
if len(room.clients) == 0 {
delete(rh.rooms, req.RoomName)
}
}
case msg := <-rh.broadcast:
if room, exists := rh.rooms[msg.RoomName]; exists {
for conn := range room.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg.Data); err != nil {
conn.Close()
delete(room.clients, conn)
}
}
}
}
}
}
Rooms are created lazily when the first client joins and cleaned up when the last client leaves. In a production system, you might add room metadata (creation time, max capacity, permissions) and persist room state to a data store for multi-instance deployments.
Ping/Pong and Connection Health
WebSocket connections can go stale without either side knowing. The WebSocket protocol includes ping and pong control frames for keep-alive checks. gorilla/websocket makes it straightforward to configure these.
import "time"
const (
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
)
func handleConnection(hub *Hub, conn *websocket.Conn) {
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Write pump: sends pings on a ticker
go func() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteControl(
websocket.PingMessage, nil, time.Now().Add(10*time.Second),
); err != nil {
return
}
}
}()
// Read pump
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
hub.broadcast <- message
}
}
The pattern works like this: you set a read deadline, and every time a pong comes back, you reset it. A separate goroutine sends pings at regular intervals. If the client stops responding, the read deadline expires, ReadMessage returns an error, and you clean up the connection.
The pingPeriod is intentionally set shorter than pongWait so the ping is sent before the read deadline expires. A 54-second ping interval with a 60-second pong timeout gives the network a comfortable window.
For more background on how ping/pong works at the protocol level, see What Is WebSocket?.
Error Handling
gorilla/websocket returns specific error types that you can inspect to understand what went wrong. The most common is *websocket.CloseError, which tells you the close code and reason.
import "errors"
func readLoop(conn *websocket.Conn) {
for {
_, message, err := conn.ReadMessage()
if err != nil {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
switch closeErr.Code {
case websocket.CloseNormalClosure:
log.Println("Client closed normally")
case websocket.CloseGoingAway:
log.Println("Client navigated away")
default:
log.Printf("Close code %d: %s", closeErr.Code, closeErr.Text)
}
} else {
log.Println("Read error:", err)
}
return
}
log.Printf("Message: %s", message)
}
}
You can also check for unexpected close errors using the helper function:
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway,
websocket.CloseNormalClosure,
) {
log.Printf("Unexpected close: %v", err)
}
This returns true if the error is a close error with a code that is not in the list you provided. It is useful for distinguishing between expected disconnections (user closed the tab) and unexpected ones (network failure).
Always handle write errors as well. If WriteMessage or WriteJSON fails, close the connection and remove it from your hub. Trying to write to a broken connection will just produce more errors.
Production Tips
TLS
In production, you should serve WebSocket connections over TLS (wss://). If you terminate TLS at a reverse proxy (nginx, Caddy, or a cloud load balancer), the Go server can stay on plain HTTP. If the Go server handles TLS directly:
log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil))
The WebSocket connection automatically upgrades over the existing TLS tunnel. No special configuration is needed in gorilla/websocket itself.
Buffer Sizes
The Upgrader accepts ReadBufferSize and WriteBufferSize in bytes. The defaults are 4096. For applications that exchange small JSON payloads, 1024 is often enough. For large binary transfers (file uploads, media streams), increase the buffers:
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}
Larger buffers consume more memory per connection. If you expect thousands of concurrent clients, profile your memory usage and tune accordingly.
Message Size Limits
Protect your server from oversized messages by setting a read limit:
conn.SetReadLimit(65536) // 64 KB max message
If a client sends a message larger than this, the connection is closed with a protocol error. Choose a limit that matches your application’s needs.
Write Timeouts
You should always set a write deadline before writing:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Println("Write timed out or failed:", err)
}
Without a write deadline, a slow or unresponsive client can block your goroutine indefinitely. In the hub pattern, a blocked write on one client can delay broadcasts to everyone else.
A common refinement is to give each client a buffered write channel and a dedicated write goroutine. The write goroutine drains the channel and writes to the connection, while the hub pushes messages into the channel. If the channel fills up (the client is too slow), you close the connection. This prevents slow clients from affecting other users.
Compression
gorilla/websocket supports per-message compression (the permessage-deflate extension). Enable it on the upgrader:
var upgrader = websocket.Upgrader{
EnableCompression: true,
}
Compression reduces bandwidth for text-heavy payloads, but adds CPU overhead. Benchmark your specific workload before enabling it in production. For small JSON messages under a few hundred bytes, the compression overhead may outweigh the savings.
Reverse Proxy Configuration
If you run behind nginx, make sure you pass the upgrade headers:
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
The proxy_read_timeout is important. Nginx defaults to 60 seconds, which will close idle WebSocket connections. Set it to a high value (24 hours in the example above) and rely on application-level ping/pong for health checks instead.
Alternatives
gorilla/websocket is stable and well-tested, but there are other options in the Go ecosystem.
nhooyr/websocket (coder/websocket)
The nhooyr/websocket package (now maintained under the coder organization) takes a more modern approach. It uses context.Context for cancellation, supports io.Reader and io.Writer interfaces, and allows concurrent writes. If you prefer an API that feels more idiomatic to modern Go, this is a strong alternative. It also has a smaller API surface, which can make it easier to learn.
Standard Library with Hijack
Go’s net/http package lets you hijack the underlying TCP connection and implement the WebSocket handshake yourself. This is educational but not practical for production. You would need to handle frame parsing, masking, fragmentation, and control messages manually. Unless you have a very specific reason (like implementing a non-standard protocol extension), stick with a library.
When to Choose gorilla/websocket
gorilla/websocket is the right pick when you want a stable, battle-tested library with a straightforward callback-style API. It has the largest ecosystem of examples, tutorials, and community support. If you are migrating from another language and want something that maps cleanly to the WebSocket protocol without extra abstractions, gorilla/websocket keeps things explicit.
For a broader overview of WebSocket libraries across languages, see the guides section on this site.
Frequently Asked Questions
Is gorilla/websocket still maintained?
The gorilla organization archived several of its repositories in late 2022, which caused concern. However, the websocket package continues to receive maintenance and security fixes. The core API has been stable for years, and the protocol itself does not change, so the library does not need frequent updates. It remains safe to use in production.
Can I use gorilla/websocket with frameworks like Gin or Echo?
Yes. Both Gin and Echo give you access to the underlying http.ResponseWriter and *http.Request, which is all the Upgrader needs. For Gin, use c.Writer and c.Request. For Echo, use c.Response() and c.Request(). The WebSocket handling code stays exactly the same.
// Gin example
r.GET("/ws", func(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// ... handle connection
})
How do I handle authentication on WebSocket connections?
The WebSocket handshake starts as an HTTP request, so you can check cookies, query parameters, or the Authorization header during the upgrade. Validate credentials in your HTTP handler before calling upgrader.Upgrade. If authentication fails, return an HTTP error and do not upgrade. After the connection is established, there is no standard way to send new headers, so re-authentication typically happens through application-level messages. For more on securing WebSocket connections, read WebSocket Security.
How many concurrent connections can a Go WebSocket server handle?
Go’s goroutine scheduler is very efficient, and each WebSocket connection typically consumes a small amount of memory (the goroutine stack starts at a few kilobytes, plus your buffer sizes). A well-tuned server on modern hardware can handle tens of thousands to hundreds of thousands of concurrent connections. The practical limit depends on your OS file descriptor limits, available memory, and how much work each connection does. Increase the ulimit -n value on Linux systems and monitor memory usage as you scale. If you are interested in how WebSocket performance compares with other protocols, check out WebSocket vs HTTP.