Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
88: Building a Simple Chat Server with WebSockets
Up until now, we've been dealing with the standard request-response cycle. You send a request, the server sends a response, and the connection closes. But if we're building a chat server, that's useless. We can't have the client polling the server every 500ms asking, "Does anyone have a message for me?" That's a great way to kill your server's performance and frustrate your users.
The Handshake Struggle
I remember the first time I tried to do this using only the standard library. I spent an hour digging through net/http before realizing that while Go supports the underlying TCP connections, it doesn't provide a high-level WebSocket implementation out of the box. To keep our sanity, I'm using github.com/gorilla/websocket. It's the industry standard for a reason.
Let's start with a basic handler. My first instinct is usually to just accept the connection and try to write to it. But WebSockets start as an HTTP request that "upgrades" to a persistent connection. If we don't handle that handshake, the browser just sees a failed HTTP request.
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Letting anyone connect for now; we'll tighten this later
},
}
func handleConnections(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println(err)
return
}
if err == nil {
log.Printf("Received: %s", p)
}
}
}
I ran this, connected a browser client, and it worked—sort of. I could send a message, and the server would print it to the console. But this is a "black hole" server. The message goes in, the server sees it, and then it disappears. A chat server needs to broadcast that message to everyone else.
The Concurrency Wall
Here is where it gets tricky. If I just keep a list of connections in a slice and loop through them to send messages, I'll run into a race condition the moment two people send a message at the same time. Go maps and slices aren't thread-safe by default, and since every WebSocket connection runs in its own goroutine, we're headed for a crash.
I tried using a sync.Mutex around a slice of connections, but that felt clunky. It blocks the entire server while one message is being sent. Instead, let's use a "Hub"—a central coordinator that manages connections via channels. This is where Go's philosophy of "don't communicate by sharing memory; share memory by communicating" actually pays off.
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
}
func newHub() *Hub {
return &Hub{
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
clients: make(map[*websocket.Conn]bool),
}
}
func (h *Hub) run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.Close()
}
case message := <-h.broadcast:
for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("error: %v", err)
client.Close()
delete(h.clients, client)
}
}
}
}
}
By moving the client management into a single run() loop, I've eliminated the need for a Mutex. Only one goroutine ever touches the clients map. The handleConnections function now just sends the connection to the register channel and pipes incoming messages into the broadcast channel.
Dealing with Ghost Connections
When I tested this with a few browser tabs, I noticed something annoying. If I closed a tab, the server would try to send a message to that closed connection and throw an error. The WriteMessage call in the loop above catches the error and deletes the client, but it's reactive—it only happens when someone else sends a message.
To fix this, I realized the reading loop in the handler needs to be the one to trigger the unregister process. The moment ReadMessage returns an error (which it does immediately when the socket closes), we tell the Hub to drop that client.
Here's the final logic for the handler integrating with the Hub:
func handleConnections(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
hub.register <- conn
// We need to ensure the client is unregistered when the function exits
defer func() {
hub.unregister <- conn
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break // Exit loop and trigger defer
}
hub.broadcast <- message
}
}
Now the flow is clean: HTTP Upgrade $\rightarrow$ Register with Hub $\rightarrow$ Read Loop $\rightarrow$ Broadcast $\rightarrow$ Unregister on disconnect. It's a tight loop that leverages Go's strengths in concurrency without getting bogged down in locking mechanisms.
📋 Practical Task
Exercise: Implementing User-Specific Nicknames in the Chat Hub
Right now, the chat server is anonymous—every message is just a string of text. Your task is to modify the server so that users can identify themselves.
- Modify the
Hubor create a newClientstruct that associates awebsocket.Connwith aUsernamestring. - Update the
handleConnectionslogic to expect the first message sent by the client to be their nickname (e.g., "Alice" or "Bob"). - Ensure that all subsequent messages broadcast to the group are prefixed with that username (e.g., "Alice: Hello everyone!").
- Handle the case where a user disconnects, ensuring their specific entry is removed from your tracking system.
There are no comments for now.