DEV Community

Cover image for How to Build a High-Performance WebSocket Server in Go for Real-Time Applications
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How to Build a High-Performance WebSocket Server in Go for Real-Time Applications

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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!"}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}()
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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)