WebSockets Explained (Golang): A Practical Guide to Real-Time Apps

Why Another WebSocket Tutorial?

When I was learning WebSockets, I struggled to find a resource that balanced simplicity with depth. Most tutorials were either too basic or too complex. This guide is for those who, like me, want to build a real chat app while understanding the reasoning behind each decision

What Are We Building?

A real-time chat application with:

  • Multiple chat rooms

  • Real-time messaging

  • Online user tracking

  • Join/leave notifications

Project Structure

chat-app/
├── websockets/
│   ├── hub.go      # Central message & connection manager
│   ├── client.go   # Individual connection handler
│   └── websocket.go # HTTP → WebSocket setup
└── main.go         # Application entry point

The Core Concepts

WebSockets

Traditional HTTP is like sending letters that you have to keep asking, "got any mail for me?" WebSockets are like having a phone call, both sides can talk whenever they want.

The Hub Pattern: Air Traffic Control for Messages

Think of the Hub as an air traffic controller. It:

  • tracks all connected users

  • Makes sure messages get to the right rooms

  • Handles users joining and leaving

Client Architecture

Each connection runs two concurrent goroutines:

  • readPump: Receives messages

  • writePump: Sends messages and maintains connection - ping-pong

Prerequisites

You should know some Go and understand what goroutines and channels are

Client Architecture

Let's Break Down Each File

1. hub.go: The Brain of Our Operation

The Hub manages all active WebSocket connections and handles message routing. It's the central component that:

  1. Tracks active clients in each chat room

  2. Handles new client connections

  3. Manages client disconnections

  4. Sends messages between clients in the same room

  5. Maintains the list of online users per room

Key Features:

  • Room-based chat (multiple chat rooms)

  • User join/leave notifications

  • Online user list per room

  • Message broadcasting to room members

In a chat app, handling tasks sequentially is too slow:

  • Multiple connections need to be managed.

  • Messages must be processed concurrently.

  • Slow clients shouldn't block the system

Goroutines: Run tasks concurrently

Channels: Allow safe communication between goroutines

CODE WALKTHROUGH:

package websockets

import (
    "encoding/json"
    "log"
    "strings"
)

// Message structure 
type Message struct {
    Type     string `json:"type"`     // Message types: chat, user_joined, user_left, online_users
    Content  string `json:"content"`   // The content
    RoomName string `json:"room"`     // The room code or id 
    Username string `json:"username"`  // The sender's username
}

// Hub struct maintains as we said its the Air traffic controller
type Hub struct {
    clients    map[*Client]bool                // All connected clients
    rooms      map[string]map[*Client]bool     // rooms:{room1:{client1, client2}}
    broadcast  chan Message                    // Channel for broadcasting messages
    register   chan *Client                    // Channel for client registration
    unregister chan *Client                    // Channel for client disconnection
}

//constructor
func NewHub() *Hub {
    return &Hub{
        clients:    make(map[*Client]bool),
        rooms:      make(map[string]map[*Client]bool),
        broadcast:  make(chan Message),
        register:   make(chan *Client),
        unregister: make(chan *Client),
    }
}

func (h *Hub) Run() {
    for {
        select {
        case client := <-h.register:
            h.handleRegister(client)
        case client := <-h.unregister:
            h.handleUnregister(client)
        case message := <-h.broadcast:
            h.handleBroadcast(message)
        }
    }
}

//----------------------------ROOM MANAGEMENT-------------------------------------
func (h *Hub) handleRegister(client *Client) {
    // Create room if doesnt exists
    if _, exists := h.rooms[client.room]; !exists {
        h.rooms[client.room] = make(map[*Client]bool)
    }

    // Add client to room in both room and client list check HUB struct
    h.rooms[client.room][client] = true
    h.clients[client] = true

    // Send online users list
    h.broadcastRoomUsers(client.room)
}

func (h *Hub) handleUnregister(client *Client) {
    if _, exists := h.clients[client]; !exists {
        return
    }

    // Remove client
    delete(h.clients, client)
    delete(h.rooms[client.room], client)

    // Notify room and update user list
    h.handleBroadcast(Message{
        Type:     "user_left",
        Content:  client.username + " left the room",
        RoomName: client.room,
        Username: client.username,
    })
    h.broadcastRoomUsers(client.room)

    // Delete if room empty 
    if len(h.rooms[client.room]) == 0 {
        delete(h.rooms, client.room)
    }
}

//----------------------------BROADCASTING MESSAGES-------------------------------------
func (h *Hub) broadcastRoomUsers(room string) {
    users := []string{}
    if roomClients, exists := h.rooms[room]; exists {
        for client := range roomClients {
            users = append(users, client.username)
        }
    }

    h.handleBroadcast(Message{
        Type:     "online_users",
        Content:  strings.Join(users, ","),
        RoomName: room,
    })
}

func (h *Hub) handleBroadcast(msg Message) {
    jsonMsg, err := json.Marshal(msg)
    if err != nil {
        log.Printf("Error marshaling message: %v", err)
        return
    }

    // Send to all clients in the room
    if roomClients, exists := h.rooms[msg.RoomName]; exists {
        for client := range roomClients {
            select {
            case client.send <- jsonMsg:
                // Message sent successfully
            default:
                // Client's buffer is full, remove them
                close(client.send)
                delete(h.clients, client)
                delete(h.rooms[msg.RoomName], client)
            }
        }
    }
}

2. client.go: Individual Connection Handler

Each client manages its own WebSocket connection with two concurrent processes. Manages their Own WebSocket connection

Key Features:

  • Concurrent message handling (read/write)

  • Connection health monitoring (ping-pong)

  • Automatic clean-up on disconnect

  • Buffer management for messages

CONCEPT TIME→

*Connection Health (Ping/Pong): Think of Ping/Pong like a heartbeat check for your WebSocket connection

EXAMPLE SCENARIO:

  1. Client connects to server

  2. Every pingPeriod (54 seconds), server sends: "ping"

  3. Client must respond with: “pong” within pongWait (60 seconds)

  4. If no pong received: "Connection dead, clean-up time!"

*Time Management: Tickers are like alarm clocks that ring at regular intervals.

REAL WORLD ANALOGY FOR ABOVE TWO:

  • a teacher checking if students are awake

  • Ticker rings every 1 minute

  • You say "ping!" and wait for "pong!"

  • No response? Student's asleep (connection dead)

*Write Deadlines: Like setting a timeout for message delivery.

EXAMPLE MIGHT BE:

  • You say "Hi!" to some girl wearing headphones

  • You wait exactly 10 seconds for a response

  • They don't hear you (timeout reached)

  • You walk away pretending it never happened 😅

package websockets

import (
    "log"
    "time"

    "github.com/gorilla/websocket"
)

// Connection management constants
// These are crucial production applications
const (
    // Time allowed to write a message to the users
    writeWait = 10 * time.Second

    // Time allowed to read the next pong message from the peer
    pongWait = 60 * time.Second

    // Send pings to peer with this period
    // Must be less than pongWait
    pingPeriod = (pongWait * 9) / 10

    // Maximum message size allowed from peer
    maxMessageSize = 512
)

// Client represents a connected websocket user
type Client struct {
    hub      *Hub            // Reference to our central hub broadcasting messages
    conn     *websocket.Conn // our WebSocket connection
    send     chan []byte     // channel for outbound messages
    room     string         // Current room name
    username string         // User's display name
}

//----------------------------READING MESSAGES-------------------------------------
func (c *Client) readPump() {
    // deferred Cleanup on function exit
    // This ensures resources are freed when connection ends
    defer func() {
        // Notify air traffic controller(HUB) that client is disconnecting
        c.hub.unregister <- c
        // Close the physical connection
        c.conn.Close()
    }()

    // Configure connection constraints
    c.conn.SetReadLimit(maxMessageSize)
    c.conn.SetReadDeadline(time.Now().Add(pongWait))
    c.conn.SetPongHandler(func(string) error {
        // Reset deadline when pong is received
        c.conn.SetReadDeadline(time.Now().Add(pongWait))
        return nil
    })

    // Main read loop
    for {
        // method to read a message
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            // Check errors 
            // bunch of errors check docs for more information
            if websocket.IsUnexpectedCloseError(err, 
                websocket.CloseGoingAway, 
                websocket.CloseAbnormalClosure) {
                log.Printf("error: %v", err)
            }
            break // Exit loop on any error
        }

        // Create message
        msg := Message{
            Type:     "chat",
            Content:  string(message),  // Convert bytes to string
            RoomName: c.room,
            Username: c.username,
        }

        // Forward message to hub for broadcasting
        c.hub.broadcast <- msg
    }
}


//----------------------------WRITING MESSAGES-------------------------------------
func (c *Client) writePump() {
    // Create ticker for pinging this maintains connection health
    ticker := time.NewTicker(pingPeriod)
    defer func() {
        ticker.Stop()
        c.conn.Close()
    }()

    for {
        select {
        case message, ok := <-c.send:
            // Set write deadline for each message
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if !ok {
                // Channel closed by hub
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }

            // Get the next writer for the connection
            w, err := c.conn.NextWriter(websocket.TextMessage)
            if err != nil {
                return
            }

            // Write the message
            w.Write(message)

            // Close the writer
            w.Close()

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

3. websocket.go: Connection Setup

This file handles the HTTP to WebSocket upgrade process.

Connection Flow:

  1. Client connects to /ws/:room?username=xxx

  2. Check room name and username

  3. Upgrade WebSocket connection

  4. Create new client

  5. Start message handling

package websockets

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/gorilla/websocket"
)


// upgrader converts HTTP connections to WebSocket connections
var upgrader = websocket.Upgrader{
    // buffersize affect memory usage and performance
    ReadBufferSize:  1024,  // Adjust based on expected message size
    WriteBufferSize: 1024,

    // accepting all for dev purpose
    CheckOrigin: func(r *http.Request) bool {
        return true // Development only In production application implement CORS correctly
    },
}

// Step 1: Extract and check connection parameters
// Step 2: Upgrade HTTP connection to WebSocket
// Step 3: Create new client
// Step 4: Register client with hub
// Step 5: Start client read/write pumps

// This is where new WebSocket connections are established
func HandleWebSocket(h *Hub) gin.HandlerFunc {
    return func(c *gin.Context) {
        // Step 1: Extract and check connection parameters
        room := c.Param("room")
        username := c.Query("username")

        // Validate required fields
        if room == "" || username == "" {
            c.JSON(http.StatusBadRequest, gin.H{"error": "room and username are required"})
            return
        }

        // Step 2: Upgrade HTTP connection to WebSocket
        conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
        if err != nil {
            log.Printf("Failed to upgrade connection: %v", err)
            return
        }

        // Step 3: Create new client 
        client := &Client{
            hub:      h,
            conn:     conn,
            send:     make(chan []byte, 256), // Buffer size affects memory usage
            room:     room,
            username: username,
        }

        // Step 4: Register client with hub
        h.register <- client

        // Create and broadcast join message
        joinMessage := Message{
            Type:     "user_joined",
            Content:  username + " joined the room",
            RoomName: room,
            Username: username,
        }
        h.broadcast <- joinMessage

        // Step 5: Start client read/write pumps
        go client.writePump() // Handles sending messages to client
        go client.readPump()  // Handles receiving messages from client
    }
}

4. main.go: Where all Connects

package main

import (
    "chat-app/websockets"
    "log"

    "github.com/gin-gonic/gin"
)

// Step 1: Initialize router and hub
// Step 2: Setup routes
// Step 3: start server

func main() {
    // Initialize router and hub
    r := gin.Default()
    hub := websockets.NewHub()
    go hub.Run()

    // Set up routes
    r.GET("/ws/:room", websockets.HandleWebSocket(hub))
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    // Start server
    log.Println("Server starting on :8080")
    if err := r.Run(":8080"); err != nil {
        log.Fatal("Server failed to start:", err)
    }
}

Testing Your Creation


1. Connect to a room: `ws://localhost:8080/ws/room1?username=Eren`
2. Connect to a room: `ws://localhost:8080/ws/room1?username=Mikasa`
3. Send messages: Just send text through the WebSocket connection
4. Receive updates: Listen for WebSocket messages for chat/status updates

Challenges?

  • Implement typing indicators

  • Add authentication

  • Store message history

Final Thoughts

We've built a complete real-time chat application using WebSockets and Go. Through this implementation, we've covered essential concepts like connection management, message broadcasting, and room-based communication.
To build upon this foundation, consider doing the challenges mentioned above. The structure we have makes it straightforward to extend the functionality with maintainable code.