Real-time applications like chat systems, dashboards, or multiplayer games rely on persistent, low-latency communication between client and server. Traditional HTTP is request-response only, but it can’t push data to the client unless the client keeps polling. That’s where WebSockets come in.
In this article, we’ll explore how WebSockets work, how connections are established and managed in Go using the Gorilla WebSocket library, and how to safely handle multiple concurrent clients with Go’s concurrency primitives.
We’ll build on a simple Go project using only the standard net/http
router and Gorilla WebSocket.
What is a WebSocket?
A WebSocket is a full-duplex communication channel over a single TCP connection. Once established, both client and server can send messages to each other anytime without re-establishing the connection.
Unlike HTTP:
- WebSockets stay open until one side closes it.
- Communication happens through lightweight frames, not HTTP requests.
- Data can flow in both directions independently.
How a WebSocket Connection is Formed
The WebSocket handshake starts as a standard HTTP GET request with an Upgrade
header:
GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
If the server accepts it, it responds with a 101 Switching Protocols
status, upgrading the connection to WebSocket. From that point, the connection is no longer HTTP, it’s a persistent socket.
In Go, this upgrade is handled by Gorilla’s Upgrader
:
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
The CheckOrigin
function lets us control which clients can connect. Setting it to always return true
allows all origins, good for development, not for production.
Connection Establishment and Storage
When a client connects to /ws
, the handler upgrades the request:
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
Once upgraded, conn
is a *websocket.Conn
, which represents an open connection to a client.
We store each connection in memory along with the client’s username (sent in the request header):
mu.Lock()
Connections = append(Connections, map[string]*websocket.Conn{username: conn})
mu.Unlock()
We use a global slice of maps to keep track of all connected users.
A sync.Mutex
ensures that concurrent reads and writes to the Connections
slice are thread-safe, since multiple clients can connect or disconnect at the same time.
Reading and Writing Messages
After establishing the connection, the server starts listening for messages:
for {
messageType, msg, err := conn.ReadMessage()
if err != nil {
log.Printf("[%s] read error: %v", username, err)
break
}
log.Printf("[%s] says: %s", username, msg)
resp := fmt.Sprintf("Response to %s: %s", username, msg)
conn.WriteMessage(messageType, []byte(resp))
}
ReadMessage()
blocks until a new message arrives. When it does, it returns the message type, the payload, and any error.
The messageType
is important because WebSockets can carry different kinds of messages.
WebSocket Message Types
- TextMessage (1): UTF-8 encoded text.
- BinaryMessage (2): Arbitrary binary data.
- PingMessage (9): Sent by one peer to check if the connection is alive.
- PongMessage (10): Sent automatically in response to a ping.
- CloseMessage (8): Sent when either side wants to terminate the connection.
Most applications only use text and binary frames, but ping/pong frames are crucial for keeping the connection healthy.
Detecting and Closing Idle Connections
In a real-world system, we don’t want idle connections hanging around forever.
We can use SetReadDeadline()
to close connections that haven’t received messages for a specific time:
const idleTimeout = 60 * time.Second
conn.SetReadDeadline(time.Now().Add(idleTimeout))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(idleTimeout))
return nil
})
This means:
- The server expects a message or pong frame every 60 seconds.
- Each time one arrives, the deadline resets.
- If the timer expires without activity,
ReadMessage()
returns a timeout error, and we close the connection.
This is how we automatically disconnect idle clients while keeping active ones alive.
Cleaning Up Disconnected Clients
When a connection closes, we must remove it from our global store to prevent memory leaks:
func removeConnection(username string) {
mu.Lock()
defer mu.Unlock()
var updated []map[string]*websocket.Conn
for _, m := range Connections {
if _, ok := m[username]; !ok {
updated = append(updated, m)
}
}
Connections = updated
}
This ensures that only active connections remain in memory.
Sending Messages to Specific Clients
Sometimes, you need to send data to a particular user. We can look up the connection by username and send directly:
func SendMessageToSpecificUser(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
targetUser := string(body)
mu.Lock()
defer mu.Unlock()
for _, item := range Connections {
for username, conn := range item {
if username == targetUser {
conn.WriteMessage(websocket.TextMessage, []byte("hi "+username))
log.Printf("Sent message to %s", username)
w.Write([]byte("sent"))
return
}
}
}
w.Write([]byte("user not found"))
}
This can easily be extended to support broadcasting messages to all clients or to a subset (like chat rooms).
Concurrency and Multithreading in WebSocket Handling
Each HTTP request in Go runs in its own goroutine.
That means every WebSocket connection gets a dedicated goroutine when the handler runs:
mux.HandleFunc("/ws", handlers.WebsocketHandler)
Inside that goroutine:
- The
for
loop reading messages is blocking, but isolated to that connection. -
sync.Mutex
ensures shared data structures (likeConnections
) remain safe when multiple goroutines modify them simultaneously. - The Go scheduler handles concurrency efficiently, thousands of WebSocket connections can run in parallel on a single server.
This concurrency model is what makes Go such a great fit for real-time applications.
Putting It All Together
Let’s walk through building and testing the complete WebSocket setup from scratch.
Project Setup
Create your project:
mkdir websocket-chat-appp
cd websocket-chat-appp
go mod init websocket-chat-appp
Install Gorilla WebSocket:
go get github.com/gorilla/websocket
Project structure:
websocket-chat-app/
│── go.mod
├── main.go
└── handlers/
└── websocket_handlers.go
main.go
package main
import (
"log"
"net/http"
"websocket-chat-app/handlers"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/ws", handlers.WebsocketHandler)
mux.HandleFunc("/show", handlers.PrintConnections)
mux.HandleFunc("POST /send", handlers.SendMessageToSpecificUser)
log.Printf("Server started on :8080")
http.ListenAndServe(":8080", mux)
}
handlers/websocket_handlers.go
package handlers
import (
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Global variables to store all active connections
var (
Connections []map[string]*websocket.Conn
mu sync.Mutex
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
// WebsocketHandler handles new WebSocket connections
func WebsocketHandler(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("username")
if username == "" {
http.Error(w, "username query param required", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
mu.Lock()
Connections = append(Connections, map[string]*websocket.Conn{username: conn})
mu.Unlock()
log.Printf("[%s] connected", username)
go handleConnection(username, conn)
}
// handleConnection reads messages and handles connection lifecycle
func handleConnection(username string, conn *websocket.Conn) {
defer func() {
removeConnection(username)
conn.Close()
log.Printf("[%s] disconnected", username)
}()
const idleTimeout = 60 * time.Second
conn.SetReadDeadline(time.Now().Add(idleTimeout))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(idleTimeout))
return nil
})
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
log.Printf("[%s] read error: %v", username, err)
break
}
log.Printf("[%s] says: %s", username, msg)
resp := fmt.Sprintf("Response to %s: %s", username, msg)
conn.WriteMessage(msgType, []byte(resp))
}
}
// removeConnection cleans up disconnected clients
func removeConnection(username string) {
mu.Lock()
defer mu.Unlock()
var updated []map[string]*websocket.Conn
for _, m := range Connections {
if _, ok := m[username]; !ok {
updated = append(updated, m)
}
}
Connections = updated
}
// PrintConnections displays all connected usernames
func PrintConnections(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
defer mu.Unlock()
fmt.Fprintln(w, "Connected users:")
for _, conn := range Connections {
for username := range conn {
fmt.Fprintln(w, "-", username)
}
}
}
// SendMessageToSpecificUser sends a message to a specific user
func SendMessageToSpecificUser(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
targetUser := string(body)
mu.Lock()
defer mu.Unlock()
for _, item := range Connections {
for username, conn := range item {
if username == targetUser {
conn.WriteMessage(websocket.TextMessage, []byte("hi "+username))
log.Printf("Sent message to %s", username)
w.Write([]byte("sent"))
return
}
}
}
w.Write([]byte("user not found"))
}
Running the Server
Start the Go server:
go run main.go
Output:
2025/10/09 22:10:41 Server started on:8080
Testing with Postman
Now, let's see how our app works.
Open Postman, then make a WebSocket connection to localhost:8080/ws
. Here I am using passing mugdho
as the username, in the header.
Let's make another WebSocket request. Here I am using "mila" as the username in the header.
Let's see the connected users using HTTP REST API
.
Now I am sending messages from the connection "Mugdho"
But I'm not sending any messages from "Mila". So the connection is closed.
Now, establish both connections again and send a message to a specific user. I am sending a message to "Mila".
Let's see if Mila received the message or not.
As you can see, Mila received the message successfully.
Summary
Here’s what we covered:
- How WebSockets work and how they differ from HTTP
- How to establish and upgrade a connection using Gorilla WebSocket
- Message types and how to read/write frames
-
How to detect idle clients using
SetReadDeadline
and close them cleanly -
Storing and managing connections safely with
sync.Mutex
- Concurrency in Go each WebSocket runs in its own goroutine
Top comments (2)
great
Thank you @meftamila