Build a Real-Time Chat App with WebSockets
HTTP is request-response: the client asks, the server answers. But what about live chat, notifications, or collaborative editing? You need the server to push data to the client the instant something happens. That's what WebSockets do. In this course, you'll build a complete real-time chat application with rooms, authentication, typing indicators, and message persistence.
What are WebSockets?
When you load a web page, your browser sends an HTTP request and the server sends a response. The connection closes. If you want new data, you have to ask again. This works fine for loading pages, but it's terrible for real-time features. Imagine a chat app where you have to refresh the page to see new messages.
ws:// (or wss:// for encrypted).The difference between HTTP and WebSocket:
HTTP (Request-Response):
Client: "Any new messages?" → Server: "No."
Client: "Any new messages?" → Server: "No."
Client: "Any new messages?" → Server: "Yes! Here's one."
Client: "Any new messages?" → Server: "No."
(Wasteful — constant polling)
WebSocket (Persistent Connection):
Client: "Open connection" → Server: "Connected."
...
Server: "New message from John!" (pushed instantly)
Server: "Jane is typing..." (pushed instantly)
Server: "New message from Jane!" (pushed instantly)
(Efficient — data pushed only when it exists)Real-world WebSocket use cases:
- • Chat applications — Slack, Discord, WhatsApp Web
- • Live notifications — GitHub, Twitter, Facebook
- • Collaborative editing — Google Docs, Figma, Notion
- • Real-time dashboards — stock prices, analytics, monitoring
- • Multiplayer games — real-time game state synchronization
Challenge: Name 3 Real-Time Apps
Name 3 applications you use daily that rely on WebSockets (or similar real-time technology) for live features. For each one, explain what data is being pushed from the server to the client in real time.
The grit-websockets Plugin
Building WebSocket handling from scratch is complex — you need connection management, message routing, room support, authentication, and graceful disconnection handling. Thegrit-websockets plugin gives you all of this out of the box.
# Install the plugin
go get github.com/MUKE-coder/grit-plugins/grit-websocketsWhat the plugin provides:
- • Hub — Central connection manager that tracks all active WebSocket connections
- • Rooms — Named groups for targeted messaging (only room members receive messages)
- • Broadcast — Send a message to all connected clients or all clients in a room
- • Auth middleware — Verify JWT tokens on WebSocket connections
- • Auto-reconnect — Client-side logic to reconnect if the connection drops
Challenge: Install the Plugin
Install the grit-websockets plugin in your Grit project. Rungo get github.com/MUKE-coder/grit-plugins/grit-websockets from yourapps/api/ directory. Verify it was added to your go.mod file.
How the Hub Pattern Works
The Hub is the brain of your WebSocket system. It manages all connections, routes messages, and tracks who is in which room. Think of it as a switchboard operator — clients connect to the Hub, and the Hub decides where to send each message.
┌─────────────────┐
Client A ────────→│ │
Client B ────────→│ Hub │──→ Room: "general" (A, B, C)
Client C ────────→│ (manages all │──→ Room: "support" (D, E)
Client D ────────→│ connections) │──→ Room: "random" (A, D)
Client E ────────→│ │
└─────────────────┘
Flow:
1. Client A sends message to "general"
2. Hub receives the message
3. Hub looks up all clients in "general" (A, B, C)
4. Hub sends the message to B and C (not back to A)
When Client D disconnects:
1. Hub removes D from all rooms
2. Hub broadcasts "D left" to rooms D was inThe Hub runs in its own goroutine (Go's lightweight thread) and processes events in a loop: register, unregister, and broadcast. This single-goroutine design prevents race conditions — only one operation happens at a time.
Challenge: Draw a Hub Diagram
Draw a diagram (on paper or in a tool) showing: 5 users (Alice, Bob, Carol, Dave, Eve), 2 rooms ("general" and "support"). Alice, Bob, and Carol are in "general". Dave and Eve are in "support". Alice is also in "support". Trace what happens when Dave sends a message to "support" — who receives it?
Setting Up WebSocket Routes
To add WebSocket support to your Grit API, you create a Hub, start it in a goroutine, and register two routes: one for general connections and one for room-specific connections.
import ws "github.com/MUKE-coder/grit-plugins/grit-websockets"
func SetupRoutes(r *gin.Engine, s *services.Services) {
// Create and start the WebSocket Hub
wsHub := ws.NewHub()
go wsHub.Run()
// General WebSocket endpoint (all connected clients)
r.GET("/ws", ws.HandleConnection(wsHub))
// Room-specific WebSocket endpoint
r.GET("/ws/room/:name", ws.HandleRoomConnection(wsHub))
// ... other API routes
}What each endpoint does:
- •
/ws— Global connection. Messages broadcast to ALL connected clients. Good for site-wide notifications - •
/ws/room/:name— Room connection. Messages only go to clients in that room. Good for chat rooms, channels, or topic-specific feeds
go wsHub.Run() line starts the Hub in a background goroutine. This is important — the Hub's event loop runs forever, processing connections and messages. Without the go keyword, your server would block here and never start.Challenge: Add WebSocket Routes
Add WebSocket routes to your Grit API. Import the plugin, create a Hub, start it withgo wsHub.Run(), and register the /ws and /ws/room/:nameendpoints. Start your API and verify the routes exist (you should see them in the startup logs).
Connecting from React
The browser has a built-in WebSocket API. No libraries needed. You create a connection, listen for events, and send messages — all with native JavaScript.
// Connect to the WebSocket server
const socket = new WebSocket('ws://localhost:8080/ws/room/general')
// Event: connection opened
socket.onopen = () => {
console.log('Connected to chat!')
}
// Event: message received from server
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('New message:', data.content)
}
// Event: connection closed
socket.onclose = () => {
console.log('Disconnected from chat')
}
// Event: error occurred
socket.onerror = (error) => {
console.error('WebSocket error:', error)
}
// Send a message
socket.send(JSON.stringify({
type: 'message',
content: 'Hello everyone!'
}))In a React component, you manage the WebSocket connection with useEffect (connect on mount, disconnect on unmount) and useState (store received messages):
// Custom hook for WebSocket chat connection
// useState: messages array, connectionStatus string
// useEffect: create WebSocket, set up handlers, cleanup on unmount
// On mount:
// const ws = new WebSocket(url)
// ws.onopen → setStatus('connected')
// ws.onmessage → add parsed message to messages array
// ws.onclose → setStatus('disconnected')
// Cleanup:
// return () => ws.close()
// Send function:
// ws.send(JSON.stringify({ type, content }))
// Return: { messages, status, send }useEffect cleanup function (return () => ws.close()) handles this automatically.Challenge: Connect from the Browser
Start your Grit API with WebSocket routes enabled. Open your browser's developer console (F12 → Console tab). Type the WebSocket connection code manually: create a new WebSocket to ws://localhost:8080/ws/room/general, set up onmessage to log received data, and send a message. Open a second browser tab and do the same. Do messages appear in both tabs?
Building the Chat UI
A chat UI has three essential parts: a message list (scrollable, auto-scrolls to the bottom when new messages arrive), an input field with a send button, and a connection status indicator.
// ChatRoom component — takes roomName as prop
//
// State:
// messages: array of { id, user, content, timestamp }
// inputValue: string (current input field text)
// status: 'connecting' | 'connected' | 'disconnected'
//
// Layout:
// <div> — full height flex container
// <header> — room name + connection status dot
// Green dot = connected, Red dot = disconnected
// </header>
//
// <div> — scrollable message list (flex-1, overflow-y-auto)
// For each message:
// <div> — message bubble
// <span> — sender name (bold)
// <p> — message content
// <span> — timestamp (muted text)
// </div>
// Auto-scroll: useRef on container, scrollTo bottom on new message
// </div>
//
// <form> — input area (sticky bottom)
// <input> — message text, onSubmit sends via WebSocket
// <button> — Send (disabled when disconnected)
// </form>
// </div>Key implementation details:
- • Auto-scroll — Use a ref on the message container. After adding a message to state, call
ref.current.scrollTo(0, ref.current.scrollHeight) - • Optimistic UI — Show the sent message immediately (don't wait for server confirmation)
- • Timestamps — Store as ISO strings, format with
new Date(ts).toLocaleTimeString() - • Connection status — Show a green/red dot so users know if they're connected
Challenge: Build the Chat UI
Build a basic chat UI component with: (1) a scrollable message list, (2) an input field with a Send button, (3) a connection status indicator (green dot for connected, red for disconnected), (4) auto-scroll to the bottom when new messages arrive. Connect it to your WebSocket endpoint and test with two browser tabs.
Rooms
Without rooms, every message goes to every connected client. Rooms let you segment conversations. A message sent to the "support" room only reaches clients who joined that room. The room name is part of the WebSocket URL.
// Connect to the "general" room
const general = new WebSocket('ws://localhost:8080/ws/room/general')
// Connect to the "support" room
const support = new WebSocket('ws://localhost:8080/ws/room/support')
// Messages sent on "general" only go to "general" clients
general.send(JSON.stringify({
type: 'message',
content: 'Hello general!'
}))
// Messages sent on "support" only go to "support" clients
support.send(JSON.stringify({
type: 'message',
content: 'I need help!'
}))A typical chat application has a sidebar listing available rooms, and the main area shows the active room's messages. When the user clicks a different room, you close the current WebSocket connection and open a new one to the selected room.
// Room switching logic:
// 1. User clicks "support" in the sidebar
// 2. Close current connection: currentSocket.close()
// 3. Open new connection: new WebSocket('ws://.../ws/room/support')
// 4. Clear message list
// 5. Load message history from REST API: GET /api/messages?room=support
// 6. Display historical messages + listen for new onesChallenge: Test Room Isolation
Create 2 rooms: "general" and "support". Open 4 browser tabs. Connect tabs 1 and 2 to "general", tabs 3 and 4 to "support". Send a message from tab 1. Does it appear in tab 2? Does it appear in tabs 3 or 4? Now send a message from tab 3. Where does it appear? This proves room isolation works.
Authentication
WebSocket connections should be authenticated just like API endpoints. You don't want anonymous users reading private chat rooms. The standard approach is to pass the JWT token as a query parameter when connecting.
// Client: pass JWT token as query parameter
const token = localStorage.getItem('access_token')
const socket = new WebSocket(
'ws://localhost:8080/ws/room/general?token=' + token
)
// Server: verify token in the connection handler
// The grit-websockets plugin does this automatically:
// 1. Extract token from query parameter
// 2. Verify JWT signature and expiration
// 3. Attach user info to the connection
// 4. Reject connection if token is invalid (HTTP 401)Why query parameters instead of headers? The browser's WebSocket API does not support custom headers. You cannot set an Authorization header on a WebSocket connection. The only way to pass auth data is through query parameters or cookies.
wss:// (WebSocket Secure) in production. Without TLS encryption, the JWT token in the query parameter is visible to anyone monitoring the network. In development, ws:// (unencrypted) is fine.Challenge: Test Authenticated Connections
Modify your WebSocket connection to include the JWT token. Test two scenarios: (1) Connect with a valid token — does the connection succeed? (2) Connect without a token or with an expired token — is the connection rejected? Check the server logs to see the authentication outcome.
Message Types
A chat app sends more than just text messages. Users join and leave rooms. Users start and stop typing. The system might send notifications. To handle all of this, structure your messages with a type field that tells the client what kind of event this is.
// Chat message — displayed in the message list
{
"type": "message",
"user": "John",
"content": "Hello everyone!",
"room": "general",
"timestamp": "2025-01-15T10:30:00Z"
}
// Typing indicator — show "John is typing..."
{
"type": "typing",
"user": "John",
"room": "general"
}
// Stop typing — hide the typing indicator
{
"type": "stop_typing",
"user": "John",
"room": "general"
}
// User joined — show notification
{
"type": "join",
"user": "Jane",
"room": "general"
}
// User left — show notification
{
"type": "leave",
"user": "Jane",
"room": "general"
}
// Online users — update the user list
{
"type": "online_users",
"room": "general",
"users": ["John", "Jane", "Bob"],
"count": 3
}On the client side, handle each type differently:
// Inside onmessage handler:
// Parse the JSON data
// Switch on data.type:
//
// case "message":
// Add to messages array, display in chat
//
// case "typing":
// Show "John is typing..." below the message list
// Set a timeout to hide it after 3 seconds
//
// case "stop_typing":
// Hide the typing indicator for that user
//
// case "join":
// Show "Jane joined the room" as a system message
// Update online user count
//
// case "leave":
// Show "Jane left the room" as a system message
// Update online user count
//
// case "online_users":
// Update the sidebar user list and countFor typing indicators, send a "typing" event when the user starts typing in the input field. Use a debounce — don't send on every keystroke. Send once when they start typing, then send "stop_typing" after 2 seconds of inactivity.
Challenge: Implement Typing Indicators
Add typing indicators to your chat app. When a user types in the input field, broadcast a "typing" event to the room. Other clients should display "John is typing..." below the message list. The indicator should disappear after 3 seconds of no typing. Test with two browser tabs — type in one, see the indicator in the other.
Summary
You've learned everything needed to build real-time features in a Grit application:
- WebSocket protocol — persistent, full-duplex connections vs HTTP request-response
- grit-websockets plugin — Hub, rooms, broadcast, and auth middleware
- Hub pattern — central connection manager that routes messages
- Room-based messaging — isolated channels for targeted communication
- React integration — useEffect for connection lifecycle, useState for messages
- Authentication — JWT token passed as query parameter
- Message types — structured events for messages, typing, join, leave
Challenge: Final Challenge: Complete Chat App — Rooms
Build the room system for your chat app:
- Create 3 rooms: "general", "random", and "help"
- Build a sidebar that lists all rooms with online user count per room
- Clicking a room switches the active WebSocket connection
- Display join/leave notifications when users enter or exit a room
Challenge: Final Challenge: Complete Chat App — Features
Add these features to your chat app:
- Typing indicators — show "John is typing..." with a 3-second timeout
- Message timestamps — display when each message was sent
- System messages — show "Jane joined" and "Bob left" notifications
- Online user list — show who is currently in the room
Challenge: Final Challenge: Message Persistence
Persist chat messages to the database so users see history when they join a room:
- Create a
Messagemodel with: content, user_id, room, timestamp - Save each message to the database when the server receives it
- Add a REST endpoint:
GET /api/messages?room=general&page=1 - When a user joins a room, load the last 50 messages from the API
- New messages arrive via WebSocket, old messages via REST
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.