Go WebSocket Real-Time Chat: Build Scalable Messaging Server from Scratch with 5 Key Architectures
Go WebSocket Real-Time Chat: Why Are You Still Polling in 2026?
Still using HTTP polling for real-time messaging? 100 requests per second overwhelming your server, 3-5 second message delays? WebSocket is the standard full-duplex real-time communication protocol. Go's goroutine model is naturally suited for high-concurrency WebSocket services. In 2026, gorilla/websocket and nhooyr.io/websocket are mature libraries that, combined with Redis Pub/Sub and Kafka, can easily build real-time chat systems supporting 100K+ concurrent connections.
This article walks through 5 key architectures, covering the full pipeline from connection management → room model → message broadcasting → persistent storage → horizontal scaling.
Core Concepts
| Concept | Description |
|---|---|
| WebSocket | Full-duplex persistent connection protocol, RFC 6455 |
| Hub | Connection management center, maintains all clients |
| Room | Chat room, isolation unit for message broadcasting |
| Client | WebSocket client connection wrapper |
| Pub/Sub | Publish-subscribe pattern for cross-process message distribution |
| Backpressure | Flow control to prevent slow clients from overwhelming the server |
| Heartbeat | Heartbeat detection for timely discovery of disconnected connections |
| Message Queue | Message queue ensuring reliable message delivery |
Problem Analysis: 5 Challenges in WebSocket Chat
- Connection management: Memory and goroutine overhead for 100K+ connections
- Message broadcasting: One message needs to be pushed to all online users in a room
- Slow clients: A slow-consuming client causing server memory overflow
- Horizontal scaling: Single machine can't handle the load, needs multi-instance coordination
- Message persistence: Offline users need to retrieve historical messages on reconnection
Step-by-Step: 5 Core WebSocket Chat Architectures
Architecture 1: Hub-Client Connection Management Model
// internal/chat/client.go
package chat
import (
"encoding/json"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
userID string
username string
rooms map[string]*Room
}
func NewClient(hub *Hub, conn *websocket.Conn, userID, username string) *Client {
return &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
userID: userID,
username: username,
rooms: make(map[string]*Room),
}
}
func (c *Client) ReadPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
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
}
var msg Message
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("invalid message: %v", err)
continue
}
msg.Sender = c.userID
msg.Timestamp = time.Now().UnixMilli()
c.hub.HandleMessage(c, &msg)
}
}
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
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// internal/chat/hub.go
package chat
import (
"log"
"sync"
)
type Hub struct {
clients map[*Client]bool
rooms map[string]*Room
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
rooms: make(map[string]*Room),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
log.Printf("client connected: %s (total: %d)", client.userID, len(h.clients))
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
for _, room := range client.rooms {
room.Leave(client)
}
}
h.mu.Unlock()
log.Printf("client disconnected: %s (total: %d)", client.userID, len(h.clients))
}
}
}
func (h *Hub) GetOrCreateRoom(roomID string) *Room {
h.mu.Lock()
defer h.mu.Unlock()
if room, ok := h.rooms[roomID]; ok {
return room
}
room := NewRoom(roomID)
h.rooms[roomID] = room
go room.Run()
log.Printf("room created: %s", roomID)
return room
}
func (h *Hub) HandleMessage(client *Client, msg *Message) {
switch msg.Type {
case "join":
room := h.GetOrCreateRoom(msg.RoomID)
room.Join(client)
case "leave":
if room, ok := h.rooms[msg.RoomID]; ok {
room.Leave(client)
}
case "chat":
if room, ok := h.rooms[msg.RoomID]; ok {
room.Broadcast(msg)
}
}
}
Architecture 2: Room Chat Room and Message Broadcasting
// internal/chat/room.go
package chat
import (
"encoding/json"
"log"
"sync"
"time"
)
type Room struct {
ID string
clients map[*Client]bool
forward chan *Message
join chan *Client
leave chan *Client
mu sync.RWMutex
}
type Message struct {
Type string `json:"type"`
RoomID string `json:"roomId"`
Sender string `json:"sender"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
}
func NewRoom(id string) *Room {
return &Room{
ID: id,
clients: make(map[*Client]bool),
forward: make(chan *Message, 100),
join: make(chan *Client),
leave: make(chan *Client),
}
}
func (r *Room) Run() {
for {
select {
case client := <-r.join:
r.mu.Lock()
r.clients[client] = true
r.mu.Unlock()
r.broadcastSystemMsg(client.username + " joined the room")
case client := <-r.leave:
r.mu.Lock()
if _, ok := r.clients[client]; ok {
delete(r.clients, client)
}
r.mu.Unlock()
r.broadcastSystemMsg(client.username + " left the room")
case msg := <-r.forward:
r.mu.RLock()
data, _ := json.Marshal(msg)
for client := range r.clients {
select {
case client.send <- data:
default:
r.mu.RUnlock()
r.mu.Lock()
delete(r.clients, client)
close(client.send)
r.mu.Unlock()
r.mu.RLock()
}
}
r.mu.RUnlock()
}
}
}
func (r *Room) Join(client *Client) {
r.join <- client
client.rooms[r.ID] = r
}
func (r *Room) Leave(client *Client) {
r.leave <- client
delete(client.rooms, r.ID)
}
func (r *Room) Broadcast(msg *Message) {
r.forward <- msg
}
func (r *Room) broadcastSystemMsg(content string) {
msg := &Message{
Type: "system",
RoomID: r.ID,
Content: content,
Timestamp: time.Now().UnixMilli(),
}
r.forward <- msg
}
Architecture 3: HTTP Upgrade and WebSocket Server
// internal/chat/server.go
package chat
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type Server struct {
hub *Hub
}
func NewServer(hub *Hub) *Server {
return &Server{hub: hub}
}
func (s *Server) ServeWS(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("userId")
username := r.URL.Query().Get("username")
if userID == "" || username == "" {
http.Error(w, "userId and username required", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
client := NewClient(s.hub, conn, userID, username)
s.hub.register <- client
go client.WritePump()
go client.ReadPump()
}
Architecture 4: Redis Pub/Sub Cross-Instance Message Distribution
// internal/chat/redis_pubsub.go
package chat
import (
"context"
"encoding/json"
"log"
"github.com/redis/go-redis/v9"
)
type RedisBroker struct {
client *redis.Client
hub *Hub
channel string
}
func NewRedisBroker(addr, channel string, hub *Hub) *RedisBroker {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
PoolSize: 10,
})
return &RedisBroker{
client: rdb,
hub: hub,
channel: channel,
}
}
func (rb *RedisBroker) Start(ctx context.Context) error {
sub := rb.client.Subscribe(ctx, rb.channel)
_, err := sub.Receive(ctx)
if err != nil {
return err
}
ch := sub.Channel()
go func() {
for msg := range ch {
var m Message
if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
log.Printf("redis message parse error: %v", err)
continue
}
if room, ok := rb.hub.rooms[m.RoomID]; ok {
room.forward <- &m
}
}
}()
return nil
}
func (rb *RedisBroker) Publish(ctx context.Context, msg *Message) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
return rb.client.Publish(ctx, rb.channel, data).Err()
}
Architecture 5: Message Persistence and History Query
// internal/chat/repository.go
package chat
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type MessageRepository struct {
db *sql.DB
}
func NewMessageRepository(databaseURL string) (*MessageRepository, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return &MessageRepository{db: db}, nil
}
func (r *MessageRepository) Save(ctx context.Context, msg *Message) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO chat_messages (room_id, sender, content, type, timestamp)
VALUES ($1, $2, $3, $4, $5)`,
msg.RoomID, msg.Sender, msg.Content, msg.Type, msg.Timestamp,
)
return err
}
func (r *MessageRepository) GetHistory(ctx context.Context, roomID string, limit, offset int) ([]*Message, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT room_id, sender, content, type, timestamp
FROM chat_messages WHERE room_id = $1
ORDER BY timestamp DESC LIMIT $2 OFFSET $3`,
roomID, limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []*Message
for rows.Next() {
msg := &Message{}
if err := rows.Scan(&msg.RoomID, &msg.Sender, &msg.Content, &msg.Type, &msg.Timestamp); err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, nil
}
Pitfall Guide
Pitfall 1: Send Channel Buffer Overflow
// ❌ Wrong: Unbuffered channel, slow client blocks broadcast
send chan []byte
// ✅ Correct: Buffered channel + overflow eviction
send chan []byte // buffered with 256 capacity
select {
case client.send <- data:
default:
delete(r.clients, client)
close(client.send)
}
Pitfall 2: Missing Heartbeat Detection
// ❌ Wrong: No ping/pong, can't detect disconnections
c.conn.SetReadDeadline(time.Time{})
// ✅ Correct: Set ping/pong heartbeat
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
Pitfall 3: Concurrent WebSocket Writes
// ❌ Wrong: Multiple goroutines writing simultaneously
go func() { c.conn.WriteMessage(1, data) }()
go func() { c.conn.WriteMessage(1, data2) }()
// ✅ Correct: Serialize writes through channel
c.send <- data
Pitfall 4: Not Handling Close Messages
// ❌ Wrong: Ignoring close frames
_, message, err := c.conn.ReadMessage()
// ✅ Correct: Distinguish normal and abnormal closes
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Printf("abnormal close: %v", err)
}
break
}
Pitfall 5: Redis Pub/Sub Message Loss
// ❌ Wrong: Broadcast before persist, messages lost on Redis disconnect
room.Broadcast(msg)
redisBroker.Publish(ctx, msg)
// ✅ Correct: Persist before broadcast
msgRepo.Save(ctx, msg)
room.Broadcast(msg)
redisBroker.Publish(ctx, msg)
Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | websocket: close sent |
Connection already closed | Check if client disconnected |
| 2 | write: broken pipe |
Writing to closed connection | Handle close channel in WritePump |
| 3 | read: connection reset |
Client abnormal disconnect | Defer cleanup in ReadPump |
| 4 | upgrade: Origin check failed |
CORS check failed | Configure upgrader.CheckOrigin |
| 5 | concurrent write to websocket |
Concurrent writes | Serialize via channel |
| 6 | channel full |
Send buffer full | Increase buffer or evict slow clients |
| 7 | redis: connection refused |
Redis not running | Check Redis connection config |
| 8 | OOM killed |
Memory overflow | Limit connections and message buffer |
| 9 | too many open files |
File descriptor exhaustion | Increase ulimit -n |
| 10 | ping timeout |
Heartbeat timeout | Adjust pongWait duration |
Advanced Optimization
- Connection rate limiting: Use token bucket to limit per-IP connection rate
- Message compression: Use permessage-deflate extension to compress WebSocket frames
- Sharded broadcasting: Broadcast to large rooms in connection shards
- Message ACK: Implement acknowledgment mechanism for at-least-once delivery
- Connection migration: Use Redis to track connection mapping for reconnection after server restart
Comparison
| Dimension | gorilla/websocket | nhooyr.io/websocket | gobwas/ws |
|---|---|---|---|
| API ease of use | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Performance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Memory usage | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Context support | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Compression | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Community activity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Documentation | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
Summary: Go's goroutine model is a natural advantage for building high-concurrency WebSocket services. Hub-Client connection management → Room message broadcasting → Redis Pub/Sub cross-instance → Message persistence → Horizontal scaling — all five in one, enabling you to build a real-time chat system supporting 100K+ concurrent connections from scratch. Key points: serialized writes, heartbeat detection, backpressure control, persist before broadcast.
Recommended Online Tools
- JSON Formatter: /en/json/format
- Hash Calculator: /en/encode/hash
- cURL to Code: /en/dev/curl-to-code
Try these browser-local tools — no sign-up required →