Building a Real-Time Chat App with WebSockets
Ever wondered how apps like Slack or Discord deliver messages instantly? The secret is WebSockets—a protocol that keeps a persistent connection between your browser and the server, enabling real-time, two-way communication.
In this post, I'll walk you through how I built a production-ready chat application from scratch. We'll cover the architecture decisions, security implementations, and the lessons I learned along the way.
What We're Building
The goal was simple: create a chat app where users can register, log in, and chat in real-time. But I wanted to go beyond a toy project and implement production-grade features:
- Real-time messaging with WebSockets
- JWT-based authentication with proper session invalidation
- Security hardening including XSS prevention, rate limiting, and SQL injection protection
- Graceful shutdown for containerized deployments
- Docker deployment on Render
Let's dive in.
The Architecture
Here's how all the pieces fit together:
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ ┌─────────────────┐ ┌─────────────────────────────────────┐ │
│ │ Login/Register │ │ Chat Interface │ │
│ │ Forms │ │ - Message input │ │
│ │ │ │ - User list │ │
│ │ │ │ - Real-time messages │ │
│ └────────┬────────┘ └──────────────┬──────────────────────┘ │
└───────────┼────────────────────────────┼────────────────────────┘
│ HTTP (REST API) │ WebSocket (wss://)
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ EXPRESS SERVER │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ REST Endpoints │ │ WebSocket Server │ │ Middleware │ │
│ │ /register │ │ - Connection │ │ - CORS │ │
│ │ /login │ │ - Message │ │ - Rate Limit │ │
│ │ /logout │ │ - Broadcast │ │ - JSON Parse │ │
│ │ /health │ │ - User List │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
└───────────┼─────────────────────┼───────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ POSTGRESQL │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ users │ │ active_tokens │ │
│ │ - id │ │ - id │ │
│ │ - username │ │ - token │ │
│ │ - password_hash │ │ - username (FK) │ │
│ │ - created_at │ │ - created_at │ │
│ └─────────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
The flow is straightforward:
- User registers or logs in via REST API
- Server generates a JWT and stores it in the database
- Client opens a WebSocket connection with the token
- Server validates the token and establishes the real-time connection
- Messages flow bidirectionally over WebSocket
- On logout, the token is deleted from the database, immediately invalidating the session
Why WebSockets?
Before building, I had to choose the right technology for real-time communication. Here's why WebSockets won out:
Traditional HTTP works like a walkie-talkie: client asks, server responds, connection closes. Great for loading web pages, terrible for chat.
Client ──── Request ────► Server
Client ◄─── Response ──── Server
(Connection closes)
WebSockets are more like a phone call: the line stays open, and either party can speak at any time.
Client ◄────────────────► Server
(Connection stays open)
(Either side can send anytime)
Here's what that looks like in code:
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
// Connection stays open
ws.on("message", (msg) => { /* handle */ });
// Server can push to client anytime
ws.send(JSON.stringify({ type: "announcement", message: "..." }));
});The difference in user experience is dramatic—message latency drops from seconds (with polling) to under 50 milliseconds with WebSockets.
Authentication: A Hybrid JWT Approach
One of the trickier parts of this project was authentication. Pure JWT tokens are stateless—once issued, they're valid until they expire. But what happens when a user logs out? With pure JWT, that token is still technically valid.
My solution: a hybrid approach that combines JWT's simplicity with server-side session control.
// On login: generate JWT and store it
const token = jwt.sign({ username }, secret, { expiresIn: '1h' });
await pool.query(
"INSERT INTO active_tokens (token, username) VALUES ($1, $2)",
[token, username]
);
// On WebSocket connection: verify JWT AND check database
jwt.verify(token, secret, async (error, decoded) => {
if (error) return done(false);
// Critical: also verify token exists in database
const result = await pool.query(
"SELECT * from active_tokens WHERE token = $1",
[token]
);
if (result.rows.length === 0) return done(false);
done(true, decoded);
});
// On logout: delete token from database
await pool.query("DELETE FROM active_tokens WHERE token = $1", [token]);This gives us the best of both worlds: JWTs are still fast to verify, but we can instantly invalidate them on logout by removing them from the database. If someone steals a token, we can revoke it immediately.
Security: Defense in Depth
A chat application is a prime target for attacks. Users type things, and those things get displayed to other users. Here's how I locked things down.
Password Hashing with bcrypt
Never store passwords in plain text. I use bcrypt with 10 salt rounds—this takes about 100ms to hash, making brute-force attacks impractical:
// Registration
const hashedPassword = await bcrypt.hash(password, 10);
await pool.query(
"INSERT INTO users (username, password_hash) VALUES ($1, $2)",
[username, hashedPassword]
);
// Login
const match = await bcrypt.compare(password, user.password_hash);XSS Prevention
If a user types <script>alert('gotcha')</script> in the chat, we don't want that executing in everyone's browser. The solution is HTML entity encoding:
function sanitize(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}Now that malicious script becomes harmless text: <script>alert('gotcha')</script>
SQL Injection Prevention
This one's simple but critical—always use parameterized queries:
// ❌ NEVER do this
const query = `SELECT * FROM users WHERE username = '${username}'`;
// ✅ Always do this
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);Rate Limiting
To prevent spam and denial-of-service attacks, I implemented two layers of rate limiting:
// HTTP: 200 requests per hour per IP
const limiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 200,
message: 'Too many requests, please try again later'
});
// WebSocket: 20 messages per 10 seconds per connection
ws.on("message", () => {
ws.messageCount = (ws.messageCount || 0) + 1;
if (ws.messageCount > 20) {
ws.close(1008, "Rate limit exceeded");
}
});Graceful Shutdown: Playing Nice with Containers
When deploying to Docker or Kubernetes, your application can be stopped at any moment. If you just kill the process, you risk:
- Losing in-flight messages
- Leaving database connections hanging
- Missing monitoring data
The solution is graceful shutdown—stop accepting new connections, finish what you're doing, clean up, then exit:
async function gracefulShutdown() {
logger.info('Shutting down gracefully...');
// 1. Stop accepting new connections
server.close(() => {
// 2. Close all WebSocket connections
wss.close(() => {
// 3. Flush monitoring data
Sentry.close(2000).then(() => {
// 4. Close database pool
pool.end(() => {
// 5. Exit cleanly
process.exit(0);
});
});
});
});
// Force exit after 10s if something hangs
setTimeout(() => process.exit(1), 10000);
}
process.on('SIGINT', gracefulShutdown); // Ctrl+C
process.on('SIGTERM', gracefulShutdown); // Docker stopThis is essential for zero-downtime deployments.
Real-Time User Presence
One feature that makes a chat app feel alive is seeing who's online. Here's how I implemented it:
interface ChatWebSocket extends WebSocket {
username?: string;
}
function getConnectedUsers(): string[] {
const users: string[] = [];
wss.clients.forEach((client: ChatWebSocket) => {
if (client.readyState === WebSocket.OPEN && client.username) {
if (!users.includes(client.username)) {
users.push(client.username);
}
}
});
return users;
}
function broadcastUserlist() {
const message = JSON.stringify({
type: "userList",
users: getConnectedUsers()
});
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}Whenever someone connects or disconnects, we broadcast the updated user list to everyone. The client then updates the UI to show who's online.
Handling Disconnections
Networks are unreliable. Users close their laptops. Connections drop. A production app needs to handle this gracefully.
On the client side, I implemented exponential backoff for reconnection:
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
function connect() {
ws = new WebSocket(wsUrl);
ws.onclose = () => {
// Exponential backoff: 5s, 10s, 20s, 30s (max)
const delay = Math.min(
5000 * Math.pow(2, reconnectAttempts),
MAX_RECONNECT_DELAY
);
reconnectAttempts++;
setTimeout(connect, delay);
};
ws.onopen = () => {
reconnectAttempts = 0; // Reset on success
};
}This prevents hammering the server when it's down while still reconnecting quickly when possible.
Scaling Beyond a Single Server
The current implementation works great for a small user base. But what if you need to handle thousands of concurrent users? Here's how I'd evolve the architecture:
┌─────────────────┐
│ Load Balancer │
│ (Sticky Sessions)│
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌───────▼───────┐
│ Redis Pub/Sub │
└───────┬───────┘
│
┌───────▼───────┐
│ PostgreSQL │
└────────────────┘
The key additions:
- Redis Pub/Sub for broadcasting messages across servers—when User A on Server 1 sends a message, Redis ensures User B on Server 3 receives it
- Sticky sessions so WebSocket connections stay on the same server
- Database read replicas to handle authentication load
Tech Stack
Here's what I used and why:
| Technology | Purpose |
|---|---|
| Node.js | Event-driven runtime perfect for handling many concurrent WebSocket connections |
| TypeScript | Type safety catches bugs at compile time, great IDE support |
| Express 5 | Mature framework with excellent middleware ecosystem |
| ws | Lightweight WebSocket library—no overhead of Socket.IO for this use case |
| PostgreSQL | Rock-solid relational database for users and tokens |
| JWT + bcrypt | Industry-standard authentication stack |
| Docker | Reproducible builds and easy deployment |
| Sentry | Error tracking and performance monitoring |
Why ws Instead of Socket.IO?
Socket.IO is fantastic for complex real-time apps—it handles reconnection, fallback protocols, and rooms out of the box. But for this project, raw WebSockets were sufficient. Using ws directly means less overhead and a better understanding of what's happening under the hood.
Deployment
I deployed the app to Render using their infrastructure-as-code approach. The render.yaml file describes everything:
services:
- type: web
name: websocket-chat-server
env: docker
dockerfilePath: ./server/Dockerfile
healthCheckPath: /health
envVars:
- key: DATABASE_URL
fromDatabase:
name: websocket-chat-db
property: connectionString
- key: JWT_SECRET
generateValue: true
databases:
- name: websocket-chat-db
plan: freeOne git push and the entire stack—server, database, and environment variables—gets deployed automatically.
What I'd Do Differently
If I were starting over, I'd consider:
- Message persistence—currently, messages disappear when you refresh
- Socket.IO—its automatic reconnection would simplify the client code
- Redis for sessions—faster than PostgreSQL for token lookups
- Typing indicators—those little "User is typing..." messages
- More tests—the code is covered, but integration tests would add confidence
Wrapping Up
Building this chat app taught me a lot about real-time systems. WebSockets are powerful but come with their own challenges—connection management, authentication, and scaling all require careful thought.
The key takeaways:
- WebSockets beat polling for real-time apps, but add complexity
- Security requires multiple layers—never trust user input
- Graceful shutdown is essential for containerized deployments
- Start simple, but design with scaling in mind
The full source code is on GitHub if you want to dig deeper. Happy building!