Skip to main content

WebSocket Real-Time Communication

Unlike HTTP, WebSocket allows the server and client to freely exchange messages in both directions once a connection is established. It is used for chat, real-time notifications, stock streaming, online games, and more. Go's gorilla/websocket library is the de facto standard for WebSocket implementation.

WebSocket Concepts​

HTTP Request/Response (unidirectional, connectionless):
Client ──GET /data──▢ Server
Client ◀──Response── Server
(connection closed)

WebSocket (bidirectional, persistent connection):
Client ──HTTP Upgrade──▢ Server
Client ◀──── Handshake complete ──── Server
Client ◀──────────── Message ──────▢ Server (full-duplex communication)
Client ◀──────────── Message ──────▢ Server
(connection maintained)

HTTP Upgrade Handshake:

Client request:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

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

Basic Usage​

go get github.com/gorilla/websocket
package main

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

"github.com/gorilla/websocket"
)

// Upgrader upgrades HTTP connection to WebSocket
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow all origins (must restrict in production)
CheckOrigin: func(r *http.Request) bool {
return true
},
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
// Upgrade HTTP β†’ WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Upgrade failed: %v", err)
return
}
defer conn.Close()

log.Printf("New connection: %s", conn.RemoteAddr())

for {
// Receive message
messageType, message, err := conn.ReadMessage()
if err != nil {
// Detect connection close
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Unexpected close: %v", err)
}
break
}

log.Printf("Received: %s", message)

// Echo response
err = conn.WriteMessage(messageType, message)
if err != nil {
log.Printf("Write failed: %v", err)
break
}
}
}

func main() {
http.HandleFunc("/ws", echoHandler)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "WebSocket echo server - connect to /ws")
})
log.Println("Server started: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

Message Types​

package main

import (
"encoding/json"
"log"
"net/http"

"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}

// ChatMessage JSON message protocol
type ChatMessage struct {
Type string `json:"type"` // "message", "join", "leave", "error"
Room string `json:"room"`
Sender string `json:"sender"`
Content string `json:"content"`
}

func jsonHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()

for {
// Handle by message type
mt, data, err := conn.ReadMessage()
if err != nil {
break
}

switch mt {
case websocket.TextMessage:
var msg ChatMessage
if err := json.Unmarshal(data, &msg); err != nil {
// Send error response on JSON parse failure
errMsg, _ := json.Marshal(ChatMessage{Type: "error", Content: "Invalid message format"})
conn.WriteMessage(websocket.TextMessage, errMsg)
continue
}
log.Printf("[%s] %s: %s", msg.Room, msg.Sender, msg.Content)

// Send response
resp, _ := json.Marshal(ChatMessage{
Type: "message",
Room: msg.Room,
Sender: "Server",
Content: "Message received: " + msg.Content,
})
conn.WriteMessage(websocket.TextMessage, resp)

case websocket.BinaryMessage:
// Handle binary data (files, images, etc.)
log.Printf("Binary message received: %d bytes", len(data))
conn.WriteMessage(websocket.BinaryMessage, data)

case websocket.CloseMessage:
log.Println("Client close request")
return
}
}
}

Ping/Pong Heartbeat​

package main

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

"github.com/gorilla/websocket"
)

const (
writeWait = 10 * time.Second // write timeout
pongWait = 60 * time.Second // pong wait time
pingPeriod = 54 * time.Second // ping send interval (shorter than pongWait)
maxMessageSize = 512 * 1024 // max message size (512KB)
)

var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}

type Client struct {
conn *websocket.Conn
send chan []byte
}

func (c *Client) readPump() {
defer func() {
c.conn.Close()
close(c.send)
}()

c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
// Refresh read deadline on pong receipt
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})

for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Read error: %v", err)
}
break
}
log.Printf("Received: %s", message)
c.send <- message // echo
}
}

func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()

for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}

case <-ticker.C:
// Send ping periodically
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

func serveWs(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}

client := &Client{
conn: conn,
send: make(chan []byte, 256),
}

// Separate read/write into separate goroutines
go client.writePump()
go client.readPump()
}

Practical Example: Multi-Room Real-Time Chat Server​

package main

import (
"encoding/json"
"log"
"net/http"
"sync"
"time"

"github.com/gorilla/websocket"
)

// Message chat message
type Message struct {
Type string `json:"type"` // "message", "join", "leave", "system"
Room string `json:"room"`
Sender string `json:"sender"`
Content string `json:"content"`
Time time.Time `json:"time"`
}

// Client connected client
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
username string
room string
}

// Hub manages clients per room
type Hub struct {
mu sync.RWMutex
rooms map[string]map[*Client]bool // room β†’ clients
join chan *Client
leave chan *Client
message chan *RoomMessage
}

type RoomMessage struct {
room string
message []byte
}

func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
join: make(chan *Client),
leave: make(chan *Client),
message: make(chan *RoomMessage, 256),
}
}

func (h *Hub) Run() {
for {
select {
case client := <-h.join:
h.mu.Lock()
if h.rooms[client.room] == nil {
h.rooms[client.room] = make(map[*Client]bool)
}
h.rooms[client.room][client] = true
h.mu.Unlock()

// Join notification
msg, _ := json.Marshal(Message{
Type: "system",
Room: client.room,
Content: client.username + " has joined",
Time: time.Now(),
})
h.broadcast(client.room, msg, nil)

case client := <-h.leave:
h.mu.Lock()
if clients, ok := h.rooms[client.room]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.rooms, client.room)
}
}
h.mu.Unlock()
close(client.send)

// Leave notification
msg, _ := json.Marshal(Message{
Type: "system",
Room: client.room,
Content: client.username + " has left",
Time: time.Now(),
})
h.broadcast(client.room, msg, nil)

case rm := <-h.message:
h.broadcast(rm.room, rm.message, nil)
}
}
}

// broadcast send message to all clients in the room
func (h *Hub) broadcast(room string, message []byte, except *Client) {
h.mu.RLock()
defer h.mu.RUnlock()

for client := range h.rooms[room] {
if client == except {
continue
}
select {
case client.send <- message:
default:
// Remove connection if buffer is full
go func(c *Client) { h.leave <- c }(client)
}
}
}

func (c *Client) readPump() {
defer func() { c.hub.leave <- c }()

c.conn.SetReadLimit(512 * 1024)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})

for {
_, data, err := c.conn.ReadMessage()
if err != nil {
break
}

var incoming Message
if err := json.Unmarshal(data, &incoming); err != nil {
continue
}

outgoing, _ := json.Marshal(Message{
Type: "message",
Room: c.room,
Sender: c.username,
Content: incoming.Content,
Time: time.Now(),
})

c.hub.message <- &RoomMessage{room: c.room, message: outgoing}
}
}

func (c *Client) writePump() {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()

for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}

func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room")
if username == "" || room == "" {
http.Error(w, "username and room parameters are required", http.StatusBadRequest)
return
}

conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Upgrade failed: %v", err)
return
}

client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
username: username,
room: room,
}

hub.join <- client

go client.writePump()
go client.readPump()
}

func main() {
hub := NewHub()
go hub.Run()

http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})

// Room list API
http.HandleFunc("/rooms", func(w http.ResponseWriter, r *http.Request) {
hub.mu.RLock()
rooms := make(map[string]int)
for room, clients := range hub.rooms {
rooms[room] = len(clients)
}
hub.mu.RUnlock()

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rooms)
})

log.Println("Chat server started: ws://localhost:8080/ws?username=john&room=general")
// Test: wscat -c "ws://localhost:8080/ws?username=john&room=general"
log.Fatal(http.ListenAndServe(":8080", nil))
}

Pro Tips​

1. Separate read/write goroutines: Since WebSocket connections handle simultaneous reads and writes, always process them in separate goroutines

2. Validate Origin: In production, always implement CheckOrigin to prevent CSRF

CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://example.com"
},

3. Limit message size: Use conn.SetReadLimit() to prevent large message bombs

4. Tune buffer channel size: The 256 in make(chan []byte, 256) is the maximum queue size per client. Set it appropriately so that slow clients do not block the server

5. Use structured message protocol: Define JSON message types instead of raw strings to make the client/server protocol explicit