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
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
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
}
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
sendchannel 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
}
}
}
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)
}
}
}
Why no mutex? Because all reads and writes to
h.clientshappen inside theselectstatement inrun(), 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")
}
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);
}
};
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);
}
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; }
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
Running It
go mod tidy
go run main.go
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
roomfield toMessageand maintain amap[string]*Hub - Persistence — write messages to PostgreSQL or Redis before broadcasting
-
Auth — validate a JWT in the
/wshandler instead of accepting any?username= -
TLS — put Nginx in front and it will upgrade
ws://towss://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)