Real-time chat with authentication, built using Express, PostgreSQL, WebSockets, and JWT tokens.
- Features
- Quick Start
- Project Structure
- Architecture
- API Reference
- WebSocket Protocol
- Configuration
- Security
- Technical Deep Dive
- Deployment
- Interview Q&A
- Troubleshooting
- Authentication: User registration/login with bcrypt password hashing and JWT tokens
- Real-time Chat: WebSocket-based messaging with automatic reconnection
- Security: Rate limiting, input sanitization, origin validation, token-based auth
- Monitoring: Winston logging and Sentry error tracking
- Modern UI: Responsive dark/light theme, connection status, user list
- Graceful Shutdown: Ensures all connections are closed properly before the server exits
- Production Ready: Docker containerization, health checks, environment-based config
# 1. Database setup
createdb websocket_chat
psql -U postgres -d websocket_chat << EOF
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE active_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(64) UNIQUE NOT NULL,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
EOF
# 2. Configure environment
cd server
cat > .env << EOF
DB_USER=postgres
DB_PASSWORD=your_password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=websocket_chat
PORT=8080
JWT_SECRET=$(openssl rand -hex 32)
NODE_ENV=development
EOF
# 3. Install & run server
npm ci
npm run dev:hot
# 4. Serve client (new terminal)
npx serve client -l 5500
# 5. Open http://localhost:5500websocket/
├── server/
│ ├── src/
│ │ ├── index.ts # Main server (WebSocket + auth endpoints)
│ │ ├── db.ts # PostgreSQL connection pool
│ │ ├── logger.ts # Winston logger configuration
│ │ ├── instrument.ts # Sentry setup
│ │ └── utils/
│ │ ├── validation.ts # Input validation functions
│ │ └── sanitize.ts # XSS prevention utilities
│ ├── Dockerfile
│ ├── package.json
│ └── tsconfig.json
├── client/
│ ├── index.html # Chat UI with login/register
│ └── styles.css
├── render.yaml # Render deployment config
└── README.md
┌─────────────────────────────────────────────────────────────────┐
│ 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 │ │
│ └─────────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
- Registration/Login: Client sends credentials via HTTP POST
- Token Generation: Server creates JWT, stores in database
- WebSocket Connection: Client connects with token in query string
- Token Verification: Server validates JWT and checks database
- Real-time Messaging: Bidirectional communication over WebSocket
- Logout: Token removed from database, invalidating the session
// Request
{ "username": "alice", "password": "secret123" }
// Response (201)
{ "message": "User created successfully", "username": "alice" }// Request
{ "username": "alice", "password": "secret123" }
// Response (200)
{ "token": "jwt-token-string", "username": "alice" }// Request
{ "token": "jwt-token-string" }
// Response (200)
{ "message": "User logout successfully" }// Response (200)
{ "status": "ok", "timeStamp": "2025-10-14T12:34:56.789Z" }Triggers a test error to verify Sentry integration.
Connection: ws://localhost:8080?token=<jwt-token>
{ "type": "chat", "message": "Hello!" }// Chat message
{ "username": "alice", "message": "Hello, "timestamp": "12:34:56 PM" }
// System announcement
{ "type": "announcement", "message": "alice has joined the chat room" }
// User list update
{ "type": "userList", "users": ["alice", "bob"] }// Server → Client
type ServerMessage =
| { type: "message"; username: string; message: string; timestamp: string }
| { type: "userList"; users: string[] }
| { type: "announcement"; message: string }
| { type: "error"; message: string };
// Client → Server
type ClientMessage =
| { type: "message"; message: string };# Database
DB_USER=postgres
DB_PASSWORD=your_password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=websocket_chat
# Server
PORT=8080
NODE_ENV=development
# Auth
JWT_SECRET=your-secret-key # Generate with: openssl rand -hex 32
# Monitoring (optional)
SENTRY_DSN=your-sentry-dsnEdit allowedOrigins in server/src/index.ts:
const allowedOrigins = [
"http://127.0.0.1:5500",
"http://localhost:8080",
"http://localhost:5500",
"ws://localhost:8080",
"https://websocket-chat-server-ptfw.onrender.com",
"wss://websocket-chat-server-ptfw.onrender.com",
"https://websocket-chat-client-ptfw.onrender.com",
"wss://websocket-chat-client-ptfw.onrender.com"
];npm run dev:hot # Hot reload (TypeScript + Nodemon)
npm run dev:watch # Watch TypeScript compilation only
npm run start:watch # Watch and restart server only
npm run build # Compile TypeScript to JavaScript
npm run start # Run the compiled JavaScript server| Feature | Implementation | Purpose |
|---|---|---|
| Password Hashing | bcrypt (10 rounds) | Protect stored passwords |
| Input Validation | Regex patterns | Prevent malformed data |
| XSS Prevention | HTML entity encoding | Prevent script injection |
| Rate Limiting | 200 req/hr, 20 msg/10s | Prevent DoS/spam |
| Origin Validation | Whitelist check | Prevent unauthorized access |
| SQL Injection | Parameterized queries | Protect database |
| Token Invalidation | Database-backed logout | Revoke compromised sessions |
// Registration - hash password
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Login - verify password
const match = await bcrypt.compare(password, user.password_hash);Why 10 salt rounds? Takes ~100ms to hash, balancing security with UX.
export function sanitize(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}export function isValidUsername(username: string): boolean {
return username.length >= 3 &&
username.length <= 50 &&
/^[a-zA-Z0-9_]+$/.test(username);
}
export function isValidPassword(password: string): boolean {
return password.length >= 8 && password.length <= 128;
}
export function isValidMessage(message: string): boolean {
return message.length >= 1 && message.length <= 500;
}// HTTP rate limiting (Express middleware)
const limiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 200, // 200 requests per hour
message: 'Too many requests, please try again later'
});
// WebSocket message rate limiting
const MESSAGE_LIMIT = 20;
const TIME_WINDOW = 10000; // 10 seconds
ws.on("message", () => {
ws.messageCount = (ws.messageCount || 0) + 1;
if (ws.messageCount > MESSAGE_LIMIT) {
ws.close(1008, "Rate limit exceeded");
}
});// ❌ VULNERABLE
// const query = \`SELECT * FROM users WHERE username = '\${username}'\`;
// ✅ SAFE - parameterized queries
const query = 'SELECT * FROM users WHERE username = $1';
const result = await pool.query(query, [username]);| Feature | HTTP Polling | WebSocket |
|---|---|---|
| Connection | New per request | Persistent |
| Latency | High (polling interval) | Low (<50ms) |
| Server Load | High (constant requests) | Low |
| Bidirectional | No (client initiates) | Yes |
| Overhead | HTTP headers each time | Minimal frame headers |
// WebSocket: Persistent bidirectional connection
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
ws.on("message", (msg) => { /* handle */ });
ws.send(JSON.stringify({ type: "announcement", message: "..." }));
});┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Server │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 1. POST /login (username, password) │ │
│────────────────────────────────────────►│ │
│ │ 2. Query user │
│ │──────────────────────────────►│
│ │ 3. Return user + hash │
│ │◄──────────────────────────────│
│ │ 4. bcrypt.compare() │
│ │ 5. Generate JWT │
│ │ 6. Store token in DB │
│ │──────────────────────────────►│
│ 7. Return { token } │ │
│◄────────────────────────────────────────│ │
│ 8. Connect WebSocket with token │ │
│────────────────────────────────────────►│ │
│ │ 9. Verify JWT + check DB │
│ 10. Connection established │ │
│◄───────────────────────────────────────►│ │
Hybrid JWT approach: Tokens stored in database enable logout invalidation while keeping JWT benefits.
| Pure JWT | Hybrid Approach |
|---|---|
| Cannot invalidate tokens | Can invalidate on logout |
| Stateless | Minimal state (active tokens only) |
| Fast verification | Slight DB overhead |
| Vulnerable if stolen | Token can be revoked |
async function gracefulShutdown() {
logger.info('Received shutdown signal...');
server.close((err) => { // 1. Stop accepting HTTP
wss.close(() => { // 2. Close WebSocket server
Sentry.close(2000).then(() => { // 3. Flush monitoring
pool.end(() => { // 4. Close database
process.exit(0); // 5. Exit cleanly
});
});
});
});
setTimeout(() => process.exit(1), 10000); // Force exit timeout
}
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);interface ChatWebSocket extends WebSocket {
username?: string;
isAlive?: boolean;
messageCount?: number;
}
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 connectedUsers = getConnectedUsers();
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: "userList", users: connectedUsers }));
}
});
}version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: websocket_chat
POSTGRES_PASSWORD: \${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build: .
depends_on: [postgres]
environment:
DB_HOST: postgres
JWT_SECRET: \${JWT_SECRET}
ports:
- "8080:8080"
volumes:
postgres_data:docker-compose up -dservices:
- 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
- key: NODE_ENV
value: production
- type: web
name: websocket-chat-client
env: static
staticPublishPath: ./client
databases:
- name: websocket-chat-db
plan: freeFROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"] ┌─────────────────┐
│ Load Balancer │
│ (Sticky Sessions)│
└────────┬────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
└───────────────────┼───────────────────┘
┌───────▼───────┐
│ Redis Pub/Sub │
└───────┬───────┘
┌───────▼───────┐
│ PostgreSQL │
│ (+ Read Replicas)│
└────────────────┘
Key scaling changes:
- Redis Pub/Sub for cross-server message broadcasting
- Sticky sessions or shared session store
- Load balancer with WebSocket support (nginx)
- Message queues (RabbitMQ/Kafka) for guaranteed delivery
- Database read replicas for authentication queries
Can't connect to database?
sudo systemctl status postgresql
psql -U postgres -d websocket_chat -c "SELECT 1;"WebSocket handshake fails (403)?
- Use HTTP server, not `file://`
- Check token is valid
- Verify origin is in `allowedOrigins`
JWT_SECRET error?
# Add to .env
JWT_SECRET=\$(openssl rand -hex 32)Rate limit hit?
- Wait 10 seconds (20 messages per 10 seconds limit)
- Client reconnects automatically
| Category | Technology | Why |
|---|---|---|
| Runtime | Node.js | Event-driven, perfect for WebSockets |
| Language | TypeScript | Type safety, compile-time errors |
| Framework | Express 5 | Mature, middleware ecosystem |
| WebSocket | ws | Lightweight, no Socket.io overhead |
| Database | PostgreSQL | ACID compliance, reliable |
| Auth | JWT + bcrypt | Stateless tokens, secure hashing |
| Logging | Winston | Log levels, environment-aware |
| Monitoring | Sentry | Error tracking, alerting |
| Deployment | Docker + Render | Reproducible, scalable |
| Category | Metric | Value |
|---|---|---|
| Performance | Message Latency | <50ms |
| Performance | Connection Time | <100ms |
| Performance | Memory Usage | ~50MB |
| Security | Password Hashing | bcrypt 10 rounds |
| Security | Token Expiry | 1 hour |
| Quality | Type Coverage | 100% TypeScript |
- ✅ Health check endpoint
- ✅ Graceful shutdown
- ✅ Environment-based config
- ✅ Docker containerization
- ✅ CORS configuration
- ✅ Rate limiting
- ✅ Error monitoring (Sentry)
- ✅ Structured logging (Winston)
MIT