As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let's talk about real-time. Think about a live sports score update on your phone, a collaborative document where you see other people's cursors moving, or a group chat where messages appear instantly. For a long time, making this happen on the web was clunky. We had techniques where your browser would constantly ask the server, "Do you have anything new for me?" over and over. It was inefficient and slow.
The modern solution is WebSocket. Imagine you call a friend. You dial, they answer, and the line stays open. You can both talk whenever you want, without hanging up and redialing. A WebSocket is like that open phone line between a browser and a server. Once connected, data can flow in both directions at any time. This is what makes truly interactive, real-time web applications possible.
Building a server that can manage thousands of these open "phone lines" efficiently is a fascinating challenge. We need to answer several questions. How do we start the call correctly every time? How do we keep track of who is connected? If someone shouts a message into a room, how do we make sure everyone else in that room hears it without overloading the system? How do we know if a line has gone dead?
I want to show you how to build such a server in Go. Go is a fantastic language for this. It's built for concurrency, which is just a fancy word for doing many things at once, like handling thousands of simultaneous connections. Its simplicity helps us focus on the core logic rather than complex language features.
Let's start by looking at the central manager of our server. We'll create a WebSocketServer struct. Think of this as the control room.
type WebSocketServer struct {
listener net.Listener
connections *ConnectionPool
rooms *RoomManager
config ServerConfig
stats ServerStats
}
This control room has a few key parts. The listener waits for new connection requests. The ConnectionPool is the main switchboard, keeping a record of every active connection. The RoomManager handles grouping connections together, like putting people into different chat rooms. The config holds our settings, like how many connections we allow. The stats help us monitor performance.
The first step in any WebSocket conversation is the handshake. A client, like a web browser, sends a special HTTP request saying, "I want to upgrade this to a WebSocket." Our server must respond correctly. This isn't our application logic yet; it's the protocol saying, "Okay, let's switch from HTTP to a persistent connection."
Here's a simplified version of that handshake inside our server:
func (ws *WebSocketServer) performHandshake(rawConn net.Conn) (net.Conn, error) {
// Read the initial HTTP request
buf := make([]byte, 4096)
n, err := rawConn.Read(buf)
if err != nil {
return nil, err
}
// Check if this is a WebSocket upgrade request
headers := parseHeaders(buf[:n])
key := headers["Sec-WebSocket-Key"]
if key == "" {
return nil, errors.New("not a websocket request")
}
// Calculate the required response key
acceptKey := calculateAcceptKey(key)
// Send back the magic "101 Switching Protocols" response
response := fmt.Sprintf(
"HTTP/1.1 101 Switching Protocols\r\n"+
"Upgrade: websocket\r\n"+
"Connection: Upgrade\r\n"+
"Sec-WebSocket-Accept: %s\r\n\r\n",
acceptKey,
)
_, err = rawConn.Write([]byte(response))
return rawConn, err
}
If this exchange succeeds, the ordinary HTTP connection transforms into a persistent WebSocket connection. Now the real work begins. We create an object to represent this single connection. We give it a unique ID, channels for sending and receiving data, and a way to close it cleanly.
type WSConnection struct {
ID uint64
Conn net.Conn
UserID string
RoomIDs []string
SendChan chan []byte
CloseChan chan struct{}
mu sync.RWMutex
}
The SendChan is crucial. In Go, channels are a safe way for different parts of your program to communicate. Instead of having one giant, tangled mess of code trying to write directly to the network connection, we have a dedicated goroutine for writing. This writer goroutine simply waits for messages to appear on the SendChan and sends them out. This pattern keeps things orderly and prevents multiple parts of the code from trying to write at the same time and causing errors.
We start three main goroutines for each connection: one for reading incoming messages, one for writing outgoing messages, and one for sending periodic "pings" to check if the connection is still alive.
The read loop continuously checks the network socket for new data. WebSocket data comes in structured chunks called frames. A frame tells us if this is a text message, binary data, a ping, a pong, or a close request. Our read loop decodes these frames.
func (ws *WebSocketServer) readHandler(conn *WSConnection) {
buffer := make([]byte, ws.config.MaxMessageSize)
for {
conn.Conn.SetReadDeadline(time.Now().Add(ws.config.ReadTimeout))
n, err := conn.Conn.Read(buffer)
if err != nil {
break // Connection is likely closed
}
frame, _ := parseFrame(buffer[:n])
switch frame.OpCode {
case OpCodeText:
ws.handleTextMessage(conn, frame.Payload)
case OpCodeClose:
ws.handleClose(conn, frame.Payload)
return
case OpCodePing:
ws.handlePing(conn, frame.Payload)
}
}
ws.closeConnection(conn)
}
When we get a text message, we need to understand what the client wants to do. In our example, we'll support a few simple actions: joining a room, leaving a room, and broadcasting a message to a room. We can define a simple message format using JSON.
{"type": "join", "room_id": "game_lobby", "user_id": "alice123"}
{"type": "broadcast", "room_id": "game_lobby", "content": "Hello everyone!"}
Our handleTextMessage function would parse this JSON and call the appropriate handler. Let's look at what happens for a "join" request.
func (ws *WebSocketServer) handleJoin(conn *WSConnection, msg *Message) {
roomID := msg.Data["room_id"].(string)
userID := msg.Data["user_id"].(string)
conn.mu.Lock()
conn.UserID = userID
conn.RoomIDs = append(conn.RoomIDs, roomID)
conn.mu.Unlock()
// Tell the room manager about this new member
ws.rooms.AddConnection(roomID, conn.ID)
// Update the connection pool's index
ws.connections.AddToRoom(roomID, conn.ID)
// Send a confirmation back to the client
response, _ := json.Marshal(map[string]string{"type": "joined", "room_id": roomID})
conn.SendChan <- response
}
Now, what about broadcasting a message? This is where performance becomes critical. If a room has 10,000 members, we need to send the message to 10,000 connections as quickly as possible, without blocking the sender.
Our RoomManager keeps a list of connection IDs for each room. When a broadcast comes in, we get that list.
func (ws *WebSocketServer) handleBroadcast(conn *WSConnection, msg *Message) {
roomID := msg.Data["room_id"].(string)
content := msg.Data["content"].(string)
connections := ws.rooms.GetConnections(roomID)
broadcastMsg, _ := json.Marshal(map[string]interface{}{
"type": "broadcast",
"content": content,
"sender": conn.UserID,
})
for _, connID := range connections {
if targetConn := ws.connections.Get(connID); targetConn != nil {
select {
case targetConn.SendChan <- broadcastMsg:
// Successfully queued the message
default:
// The SendChan is full. The client might be slow.
// We can drop the message or handle the backlog.
log.Println("Client buffer full, dropping message for", connID)
}
}
}
}
Notice the select statement with a default case. This is a non-blocking send. If the client's SendChan is full (maybe their network is slow), we don't want to wait. We simply skip them and move on. This prevents one slow client from holding up messages for everyone else. You might choose to close the connection instead, depending on your application's needs.
Keeping connections alive is another important job. Networks are unreliable. A client might lose WiFi or close their laptop. We need to detect this to free up resources. We do this with a heartbeat. The pingHandler sends a small "ping" frame at regular intervals.
func (ws *WebSocketServer) pingHandler(conn *WSConnection) {
ticker := time.NewTicker(ws.config.PingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pingFrame := createFrame(OpCodePing, []byte("ping"), true)
conn.Conn.SetWriteDeadline(time.Now().Add(ws.config.WriteTimeout))
conn.Conn.Write(pingFrame)
// Start a timer, waiting for the "pong" reply
go ws.waitForPong(conn)
case <-conn.CloseChan:
return
}
}
}
func (ws *WebSocketServer) waitForPong(conn *WSConnection) {
select {
case <-time.After(ws.config.PongTimeout):
ws.closeConnection(conn) // No pong received, close it.
case <-conn.CloseChan:
return
}
}
If we send a ping and don't get a pong response within a set time, we assume the connection is dead and close it. This cleanup is vital. The closeConnection function is the orderly shutdown procedure. It removes the connection from all rooms, deletes it from the main connection pool, closes the network socket, and updates our statistics.
Talking about statistics, let's see how we can monitor our server. We can use atomic counters to track metrics without using heavy locks.
type ServerStats struct {
ConnectionsTotal uint64
ConnectionsActive uint64
MessagesSent uint64
MessagesReceived uint64
MessagesPerSecond float64
}
func (ws *WebSocketServer) collectStats() {
ticker := time.NewTicker(1 * time.Second)
var lastMessageCount uint64
for range ticker.C {
current := atomic.LoadUint64(&ws.stats.MessagesSent) + atomic.LoadUint64(&ws.stats.MessagesReceived)
delta := current - lastMessageCount
ws.stats.MessagesPerSecond = float64(delta) // Messages in the last second
lastMessageCount = current
}
}
We can expose these stats via a simple HTTP endpoint on a different port, which is safe and common for health checks.
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
stats := server.GetStats()
fmt.Fprintf(w, `{"active_connections": %d}`, stats.ConnectionsActive)
})
http.ListenAndServe(":8081", nil)
}()
Finally, we start everything in our main function.
func main() {
server, err := NewWebSocketServer(":8080")
if err != nil {
log.Fatal(err)
}
log.Println("Server starting on :8080")
log.Fatal(server.Start())
}
When you run this, you have a functional WebSocket server. It accepts connections, groups them into rooms, broadcasts messages efficiently, and cleans up after itself. This is a solid foundation. From here, you could add more features: user authentication before joining rooms, private direct messages between users, saving message history to a database, or scaling horizontally by connecting multiple server instances together through a message bus like Redis.
Building this piece by piece helps you understand what makes real-time systems tick. You see how connections are managed as resources, how messages are routed without bottlenecks, and how the system stays healthy under load. It's a satisfying project that brings a fundamental web technology to life.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)