DEV Community

Cover image for Build a Real-Time Chat App with Go, Gin & WebSockets
Daniel Keya
Daniel Keya

Posted on • Originally published at github.com

Build a Real-Time Chat App with Go, Gin & WebSockets

Build a Real-Time Chat App with Go, Gin & WebSockets

WebSockets make real-time apps feel alive. In this tutorial we're going to build a fully functional multi-user chat app using Go, Gin, and Gorilla WebSocket — with sent/received message bubbles, a live online user counter, and a zero-dependency vanilla JS frontend.

The full source code is on GitHub: keyadaniel56/golang-chatapp


What We're Building

  • Multi-user real-time chat over WebSockets
  • Sent messages aligned right (yellow-green), received messages aligned left (teal)
  • Live "N ONLINE" counter in the header that updates instantly on join/leave
  • Join/leave system messages
  • A clean terminal-aesthetic dark UI — no frontend framework needed

Prerequisites

  • Go 1.22+
  • Basic familiarity with Go syntax
  • A terminal

Project Structure

golang-chatapp/
├── main.go          # All server logic
├── static/
│   └── index.html   # Frontend (vanilla JS)
├── go.mod
└── go.sum
Enter fullscreen mode Exit fullscreen mode

Step 1 — Initialize the Module

mkdir golang-chatapp && cd golang-chatapp
go mod init chatapp
go get github.com/gin-gonic/gin
go get github.com/gorilla/websocket
Enter fullscreen mode Exit fullscreen mode

Step 2 — The Message Type

Every piece of data flowing through the system is a Message:

type Message struct {
    Username  string `json:"username"`
    Text      string `json:"text"`
    Time      string `json:"time"`
    Type      string `json:"type"`       // "message" | "join" | "leave"
    UserCount int    `json:"user_count"` // set on join/leave events
}
Enter fullscreen mode Exit fullscreen mode

The UserCount field lets the frontend update the online counter without a separate REST poll every time someone connects or disconnects.


Step 3 — The Client

Each browser tab that connects becomes a Client. It has two goroutines:

  • readPump — reads incoming JSON from the WebSocket and hands it off to the Hub
  • writePump — drains the send channel and writes JSON back to the browser
type Client struct {
    hub      *Hub
    conn     *websocket.Conn
    send     chan Message
    username string
}

func (c *Client) readPump() {
    defer func() {
        c.hub.unregister <- c
        c.conn.Close()
    }()
    for {
        var msg Message
        if err := c.conn.ReadJSON(&msg); err != nil {
            break
        }
        msg.Username = c.username
        msg.Type = "message"
        c.hub.broadcast <- msg
    }
}

func (c *Client) writePump() {
    defer c.conn.Close()
    for msg := range c.send {
        if err := c.conn.WriteJSON(msg); err != nil {
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When readPump exits (the browser closes or drops), it automatically fires unregister on the Hub — so the leave message and updated count get broadcast immediately.


Step 4 — The Hub

The Hub is the heart of the app. It runs in a single goroutine and is the only place that touches the clients map — no mutexes needed.

type Hub struct {
    clients    map[*Client]bool
    broadcast  chan Message
    register   chan *Client
    unregister chan *Client
}

func (h *Hub) run() {
    for {
        select {
        case client := <-h.register:
            h.clients[client] = true
            h.broadcastAll(Message{
                Username:  client.username,
                Text:      client.username + " joined the chat",
                Type:      "join",
                UserCount: len(h.clients),
            })

        case client := <-h.unregister:
            if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                close(client.send)
                h.broadcastAll(Message{
                    Username:  client.username,
                    Text:      client.username + " left the chat",
                    Type:      "leave",
                    UserCount: len(h.clients),
                })
            }

        case msg := <-h.broadcast:
            h.broadcastAll(msg)
        }
    }
}

func (h *Hub) broadcastAll(msg Message) {
    for client := range h.clients {
        select {
        case client.send <- msg:
        default:
            close(client.send)
            delete(h.clients, client)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why no mutex? Because all reads and writes to h.clients happen inside the select statement in run(), which executes sequentially in one goroutine. The channels are the synchronization primitive.


Step 5 — Gin Routes

func main() {
    hub := newHub()
    go hub.run()

    r := gin.Default()

    r.Static("/static", "./static")
    r.GET("/", func(c *gin.Context) {
        c.File("./static/index.html")
    })

    r.GET("/ws", func(c *gin.Context) {
        username := c.Query("username")
        if username == "" {
            c.JSON(http.StatusBadRequest, gin.H{"error": "username required"})
            return
        }
        conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
        if err != nil { return }

        client := &Client{
            hub: hub, conn: conn,
            send: make(chan Message, 256),
            username: username,
        }
        hub.register <- client
        go client.writePump()
        go client.readPump()
    })

    r.GET("/api/users", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"online": len(hub.clients)})
    })

    r.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

Three routes:

  • / — serves the frontend HTML
  • /ws?username=Alice — upgrades to WebSocket and registers the client
  • /api/users — returns the online count (used on initial page load)

Step 6 — The Frontend

The frontend is pure vanilla JS — no React, no Vue, just a WebSocket object and some DOM manipulation.

Connecting:

const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws?username=${encodeURIComponent(me)}`);

ws.onmessage = e => {
    const m = JSON.parse(e.data);
    if (m.type === 'join' || m.type === 'leave') {
        sysMsg(m.text, m.type);
        if (m.user_count !== undefined) setCount(m.user_count);
    } else {
        bubble(m);
    }
};
Enter fullscreen mode Exit fullscreen mode

Rendering sent vs received bubbles:

function bubble(msg) {
    const isSelf = msg.username === me;
    const row = document.createElement('div');
    row.className = `bubble-row ${isSelf ? 'sent' : 'recv'}`;
    row.innerHTML = `
        <div class="dir-label">${isSelf ? '▲ SENT' : '▼ RECEIVED'}</div>
        <div class="bubble-meta">
            <span class="bubble-name">${esc(msg.username)}</span>
            <span class="bubble-time">${timestamp()}</span>
        </div>
        <div class="bubble">${esc(msg.text)}</div>`;
    append(row);
}
Enter fullscreen mode Exit fullscreen mode

Sent messages (isSelf = true) align right with a yellow-green palette. Received messages align left with teal. The CSS does the heavy lifting:

.bubble-row.sent  { align-self: flex-end;  }
.bubble-row.recv  { align-self: flex-start; }

.bubble-row.sent  .bubble { background: #1a2604; border-color: #334d08; border-radius: 10px 2px 10px 10px; }
.bubble-row.recv  .bubble { background: #041a15; border-color: #0d3328; border-radius: 2px 10px 10px 10px; }
Enter fullscreen mode Exit fullscreen mode

The asymmetric border radii (one sharp corner per bubble, on the side closest to the edge) are a subtle detail that makes sent vs received feel immediately distinct even without reading the label.


How It All Flows

Browser opens /ws?username=Alice
    → Gin upgrades to WebSocket
    → New Client created
    → hub.register ← client
    → Hub broadcasts join + user count to everyone
    → Frontend updates online counter

Alice types "hello"
    → ws.send({ text: "hello" })
    → Client.readPump reads it
    → hub.broadcast ← message
    → Hub loops all clients, pushes to send channels
    → Client.writePump writes JSON to each browser
    → Each browser renders bubble (sent=right / recv=left)

Alice closes tab
    → readPump exits, fires hub.unregister
    → Hub removes client, broadcasts leave + updated count
Enter fullscreen mode Exit fullscreen mode

Running It

go mod tidy
go run main.go
Enter fullscreen mode Exit fullscreen mode

Open two tabs at http://localhost:8080, enter different usernames, and start chatting. You'll see the online counter tick up to 2, and each tab will show its own messages on the right and the other person's messages on the left.


What to Build Next

  • Rooms/channels — add a room field to Message and maintain a map[string]*Hub
  • Persistence — write messages to PostgreSQL or Redis before broadcasting
  • Auth — validate a JWT in the /ws handler instead of accepting any ?username=
  • TLS — put Nginx in front and it will upgrade ws:// to wss:// automatically
  • Docker — the app is a single binary, a two-line Dockerfile is all you need

Source Code

GitHub: keyadaniel56/golang-chatapp

If this helped you, drop a ⭐ on the repo and share it with someone learning Go. Happy coding!

Top comments (0)