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

  1. Connection management: Memory and goroutine overhead for 100K+ connections
  2. Message broadcasting: One message needs to be pushed to all online users in a room
  3. Slow clients: A slow-consuming client causing server memory overflow
  4. Horizontal scaling: Single machine can't handle the load, needs multi-instance coordination
  5. 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

  1. Connection rate limiting: Use token bucket to limit per-IP connection rate
  2. Message compression: Use permessage-deflate extension to compress WebSocket frames
  3. Sharded broadcasting: Broadcast to large rooms in connection shards
  4. Message ACK: Implement acknowledgment mechanism for at-least-once delivery
  5. 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.


Try these browser-local tools — no sign-up required →

#Go WebSocket#实时聊天#gorilla/websocket#Go实时通信#2026#技术架构