DEV Community

Cover image for Building A Real-Time Communication System Using Go and WebSocket
M. Oly Mahmud
M. Oly Mahmud

Posted on

Building A Real-Time Communication System Using Go and WebSocket

Real-time applications like chat systems, dashboards, or multiplayer games rely on persistent, low-latency communication between client and server. Traditional HTTP is request-response only, but it can’t push data to the client unless the client keeps polling. That’s where WebSockets come in.

In this article, we’ll explore how WebSockets work, how connections are established and managed in Go using the Gorilla WebSocket library, and how to safely handle multiple concurrent clients with Go’s concurrency primitives.

We’ll build on a simple Go project using only the standard net/http router and Gorilla WebSocket.

What is a WebSocket?

A WebSocket is a full-duplex communication channel over a single TCP connection. Once established, both client and server can send messages to each other anytime without re-establishing the connection.

Unlike HTTP:

  • WebSockets stay open until one side closes it.
  • Communication happens through lightweight frames, not HTTP requests.
  • Data can flow in both directions independently.

How a WebSocket Connection is Formed

The WebSocket handshake starts as a standard HTTP GET request with an Upgrade header:

GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Enter fullscreen mode Exit fullscreen mode

If the server accepts it, it responds with a 101 Switching Protocols status, upgrading the connection to WebSocket. From that point, the connection is no longer HTTP, it’s a persistent socket.

In Go, this upgrade is handled by Gorilla’s Upgrader:

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin:     func(r *http.Request) bool { return true },
}
Enter fullscreen mode Exit fullscreen mode

The CheckOrigin function lets us control which clients can connect. Setting it to always return true allows all origins, good for development, not for production.

Connection Establishment and Storage

When a client connects to /ws, the handler upgrades the request:

conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
    log.Println("Upgrade error:", err)
    return
}
Enter fullscreen mode Exit fullscreen mode

Once upgraded, conn is a *websocket.Conn, which represents an open connection to a client.
We store each connection in memory along with the client’s username (sent in the request header):

mu.Lock()
Connections = append(Connections, map[string]*websocket.Conn{username: conn})
mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

We use a global slice of maps to keep track of all connected users.
A sync.Mutex ensures that concurrent reads and writes to the Connections slice are thread-safe, since multiple clients can connect or disconnect at the same time.

Reading and Writing Messages

After establishing the connection, the server starts listening for messages:

for {
    messageType, msg, err := conn.ReadMessage()
    if err != nil {
        log.Printf("[%s] read error: %v", username, err)
        break
    }

    log.Printf("[%s] says: %s", username, msg)

    resp := fmt.Sprintf("Response to %s: %s", username, msg)
    conn.WriteMessage(messageType, []byte(resp))
}
Enter fullscreen mode Exit fullscreen mode

ReadMessage() blocks until a new message arrives. When it does, it returns the message type, the payload, and any error.

The messageType is important because WebSockets can carry different kinds of messages.

WebSocket Message Types

  1. TextMessage (1): UTF-8 encoded text.
  2. BinaryMessage (2): Arbitrary binary data.
  3. PingMessage (9): Sent by one peer to check if the connection is alive.
  4. PongMessage (10): Sent automatically in response to a ping.
  5. CloseMessage (8): Sent when either side wants to terminate the connection.

Most applications only use text and binary frames, but ping/pong frames are crucial for keeping the connection healthy.

Detecting and Closing Idle Connections

In a real-world system, we don’t want idle connections hanging around forever.
We can use SetReadDeadline() to close connections that haven’t received messages for a specific time:

const idleTimeout = 60 * time.Second
conn.SetReadDeadline(time.Now().Add(idleTimeout))
conn.SetPongHandler(func(string) error {
    conn.SetReadDeadline(time.Now().Add(idleTimeout))
    return nil
})
Enter fullscreen mode Exit fullscreen mode

This means:

  • The server expects a message or pong frame every 60 seconds.
  • Each time one arrives, the deadline resets.
  • If the timer expires without activity, ReadMessage() returns a timeout error, and we close the connection.

This is how we automatically disconnect idle clients while keeping active ones alive.

Cleaning Up Disconnected Clients

When a connection closes, we must remove it from our global store to prevent memory leaks:

func removeConnection(username string) {
    mu.Lock()
    defer mu.Unlock()

    var updated []map[string]*websocket.Conn
    for _, m := range Connections {
        if _, ok := m[username]; !ok {
            updated = append(updated, m)
        }
    }
    Connections = updated
}
Enter fullscreen mode Exit fullscreen mode

This ensures that only active connections remain in memory.

Sending Messages to Specific Clients

Sometimes, you need to send data to a particular user. We can look up the connection by username and send directly:

func SendMessageToSpecificUser(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    targetUser := string(body)

    mu.Lock()
    defer mu.Unlock()

    for _, item := range Connections {
        for username, conn := range item {
            if username == targetUser {
                conn.WriteMessage(websocket.TextMessage, []byte("hi "+username))
                log.Printf("Sent message to %s", username)
                w.Write([]byte("sent"))
                return
            }
        }
    }
    w.Write([]byte("user not found"))
}
Enter fullscreen mode Exit fullscreen mode

This can easily be extended to support broadcasting messages to all clients or to a subset (like chat rooms).

Concurrency and Multithreading in WebSocket Handling

Each HTTP request in Go runs in its own goroutine.
That means every WebSocket connection gets a dedicated goroutine when the handler runs:

mux.HandleFunc("/ws", handlers.WebsocketHandler)
Enter fullscreen mode Exit fullscreen mode

Inside that goroutine:

  • The for loop reading messages is blocking, but isolated to that connection.
  • sync.Mutex ensures shared data structures (like Connections) remain safe when multiple goroutines modify them simultaneously.
  • The Go scheduler handles concurrency efficiently, thousands of WebSocket connections can run in parallel on a single server.

This concurrency model is what makes Go such a great fit for real-time applications.

Putting It All Together

Let’s walk through building and testing the complete WebSocket setup from scratch.

Project Setup

Create your project:

mkdir websocket-chat-appp
cd websocket-chat-appp
go mod init websocket-chat-appp
Enter fullscreen mode Exit fullscreen mode

Install Gorilla WebSocket:

go get github.com/gorilla/websocket
Enter fullscreen mode Exit fullscreen mode

Project structure:

websocket-chat-app/
│── go.mod
├── main.go
└── handlers/
    └── websocket_handlers.go
Enter fullscreen mode Exit fullscreen mode

main.go

package main

import (
    "log"
    "net/http"
    "websocket-chat-app/handlers"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/ws", handlers.WebsocketHandler)
    mux.HandleFunc("/show", handlers.PrintConnections)
    mux.HandleFunc("POST /send", handlers.SendMessageToSpecificUser)

    log.Printf("Server started on :8080")
    http.ListenAndServe(":8080", mux)
}
Enter fullscreen mode Exit fullscreen mode

handlers/websocket_handlers.go

package handlers

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "sync"
    "time"

    "github.com/gorilla/websocket"
)

// Global variables to store all active connections
var (
    Connections []map[string]*websocket.Conn
    mu          sync.Mutex
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin:     func(r *http.Request) bool { return true },
}

// WebsocketHandler handles new WebSocket connections
func WebsocketHandler(w http.ResponseWriter, r *http.Request) {
    username := r.URL.Query().Get("username")
    if username == "" {
        http.Error(w, "username query param required", http.StatusBadRequest)
        return
    }

    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("Upgrade error:", err)
        return
    }

    mu.Lock()
    Connections = append(Connections, map[string]*websocket.Conn{username: conn})
    mu.Unlock()

    log.Printf("[%s] connected", username)

    go handleConnection(username, conn)
}

// handleConnection reads messages and handles connection lifecycle
func handleConnection(username string, conn *websocket.Conn) {
    defer func() {
        removeConnection(username)
        conn.Close()
        log.Printf("[%s] disconnected", username)
    }()

    const idleTimeout = 60 * time.Second
    conn.SetReadDeadline(time.Now().Add(idleTimeout))
    conn.SetPongHandler(func(string) error {
        conn.SetReadDeadline(time.Now().Add(idleTimeout))
        return nil
    })

    for {
        msgType, msg, err := conn.ReadMessage()
        if err != nil {
            log.Printf("[%s] read error: %v", username, err)
            break
        }

        log.Printf("[%s] says: %s", username, msg)
        resp := fmt.Sprintf("Response to %s: %s", username, msg)
        conn.WriteMessage(msgType, []byte(resp))
    }
}

// removeConnection cleans up disconnected clients
func removeConnection(username string) {
    mu.Lock()
    defer mu.Unlock()

    var updated []map[string]*websocket.Conn
    for _, m := range Connections {
        if _, ok := m[username]; !ok {
            updated = append(updated, m)
        }
    }
    Connections = updated
}

// PrintConnections displays all connected usernames
func PrintConnections(w http.ResponseWriter, _ *http.Request) {
    mu.Lock()
    defer mu.Unlock()

    fmt.Fprintln(w, "Connected users:")
    for _, conn := range Connections {
        for username := range conn {
            fmt.Fprintln(w, "-", username)
        }
    }
}

// SendMessageToSpecificUser sends a message to a specific user
func SendMessageToSpecificUser(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    targetUser := string(body)

    mu.Lock()
    defer mu.Unlock()

    for _, item := range Connections {
        for username, conn := range item {
            if username == targetUser {
                conn.WriteMessage(websocket.TextMessage, []byte("hi "+username))
                log.Printf("Sent message to %s", username)
                w.Write([]byte("sent"))
                return
            }
        }
    }
    w.Write([]byte("user not found"))
}
Enter fullscreen mode Exit fullscreen mode

Running the Server

Start the Go server:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Output:

2025/10/09 22:10:41 Server started on:8080
Enter fullscreen mode Exit fullscreen mode

Testing with Postman

Now, let's see how our app works.
Open Postman, then make a WebSocket connection to localhost:8080/ws. Here I am using passing mugdho as the username, in the header.

WebSocket Request - Mugdho

Let's make another WebSocket request. Here I am using "mila" as the username in the header.

WebSocket Connection - Mila

Let's see the connected users using HTTP REST API.

Show All Connected Users - 2 users

Now I am sending messages from the connection "Mugdho"

But I'm not sending any messages from "Mila". So the connection is closed.

Now, establish both connections again and send a message to a specific user. I am sending a message to "Mila".

Let's see if Mila received the message or not.

As you can see, Mila received the message successfully.

Summary

Here’s what we covered:

  • How WebSockets work and how they differ from HTTP
  • How to establish and upgrade a connection using Gorilla WebSocket
  • Message types and how to read/write frames
  • How to detect idle clients using SetReadDeadline and close them cleanly
  • Storing and managing connections safely with sync.Mutex
  • Concurrency in Go each WebSocket runs in its own goroutine

Top comments (2)

Collapse
 
meftamila profile image
Meftahul Jannat Mila

great

Collapse
 
olymahmud profile image
M. Oly Mahmud

Thank you @meftamila