A real-time messaging backend designed to handle presence tracking, message delivery, and efficient data retrieval using WebSockets and Redis.
Users join rooms, exchange messages, and maintain accurate online status even during unexpected disconnects.
- Overview
- Key Engineering Highlights
- Features
- Architecture
- Tech Stack
- Project Structure
- Getting Started
- Docker
- Environment Variables
- API Reference
- Socket Events
- Key Engineering Decisions
- Security
- Limitations & Future Improvements
- Deployment
- Author
This API powers the backend of a real-time chat application. It handles user authentication, room-based group messaging, private DMs, image sharing, and online status tracking.
The core engineering challenge: how do you track online status when a user's browser crashes without firing a disconnect event? This is solved using Redis TTL — the status key auto-expires after 30 seconds unless renewed by a client heartbeat, ensuring ghost online statuses never persist.
- Handles presence tracking using Redis TTL to avoid stale "online" states
- Reduces database load by caching recent messages (last 50) in Redis
- Uses Socket.io middleware for one-time JWT authentication per connection
- Ensures consistent DM room creation using deterministic ID logic (
Math.min/max)
- JWT authentication with socket-level authorization
- Real-time messaging using Socket.io (rooms + DMs)
- Presence tracking using Redis TTL with heartbeat mechanism
- Message caching (last 50 messages per room) for fast room joins
- Image upload and sharing via HTTP + broadcast via sockets
- Optimized PostgreSQL queries with proper indexing and constraints
High-level design of the real-time messaging system showing how WebSocket connections, Redis, and database interactions work together.
This system differs from traditional REST APIs by maintaining persistent connections using Socket.io.
Key flow:
- Client establishes WebSocket connection
- JWT authentication is verified once at connection
- Messages are stored in PostgreSQL (source of truth)
- Redis is used for presence tracking (TTL-based) and caching recent messages (last 50 per room)
- Events are broadcasted to connected clients in real-time
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 |
| Framework | Express.js |
| Language | TypeScript |
| Database | PostgreSQL 15 |
| Cache & Pub/Sub | Redis 7 |
| Real-Time | Socket.io 4 |
| Authentication | JWT + bcrypt |
| File Uploads | Multer |
| Containerization | Docker |
| Hosting | Render |
src/
├── index.ts # App entry point, middleware registration
├── config/
│ ├── db.ts # PostgreSQL connection pool
│ ├── redis.ts # Redis connection with error handling
│ ├── multer.ts # Multer config for image uploads
│ └── runMigrations.ts # Migration runner
├── middleware/
│ └── auth.middleware.ts # JWT socket auth middleware
├── routes/
│ ├── auth.route.ts # Register and login endpoints
│ └── upload.route.ts # Image upload endpoint
├── services/
│ ├── auth.service.ts # bcrypt + JWT logic
│ └── message.service.ts # DB persistence + Redis caching
├── socket/
│ └── index.ts # All Socket.io event handlers
└── migrations/
└── init.sql # Database schema
- Node.js 20+
- PostgreSQL 15+
- Redis 7+
- npm
git clone https://github.com/TirthWillLearn/Realtime-Chat-App.git
cd Realtime-Chat-Appnpm installcp .env.example .envFill in your database credentials, Redis config, and JWT secret (see Environment Variables).
npx ts-node src/config/runMigrations.tsOr run the SQL manually against your PostgreSQL database:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(100) UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS room_members (
room_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
room_id INT NOT NULL,
user_id INT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);npm run devServer runs at http://localhost:4000
docker-compose up --buildThis starts three containers:
- app — Node.js API on port 4000
- postgres — PostgreSQL 15
- redis — Redis 7
docker build -t realtime-chat-api .
docker run --env-file .env.production -p 4000:4000 realtime-chat-apiPORT=4000
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=chat_app
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
JWT_SECRET=your_jwt_secret
NODE_ENV=developmentNever commit your
.envfile. Use.env.exampleas a reference template.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/auth/register |
Public | Register and receive JWT |
| POST | /api/auth/login |
Public | Login and receive JWT |
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/uploads |
Public | Upload image, receive URL back |
POST /api/auth/register
{
"email": "tirth@example.com",
"password": "securepass"
}{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}POST /api/auth/login
{
"email": "tirth@example.com",
"password": "securepass"
}{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}POST /api/uploads — Content-Type: multipart/form-data
field: image
value: [file]
{
"url": "https://realtime-chat-api-78gu.onrender.com/uploads/chat-1774467640822.jpeg"
}Connect to the server using a Socket.io client. Pass JWT in the handshake:
const socket = io("https://realtime-chat-api-78gu.onrender.com/", {
auth: { token: "Bearer your_jwt_token" },
});| Event | Payload | Description |
|---|---|---|
joinRoom |
roomId |
Join a chat room |
leaveRoom |
roomId |
Leave a chat room |
sendMessage |
roomId, message |
Send a message to a room |
joinDM |
targetUserId |
Start a private DM with another user |
| Event | Payload | Description |
|---|---|---|
message |
string or object |
Incoming message or cached message history |
error |
{ message: string } |
Error from server (e.g. failed to send) |
// Join room
socket.emit("joinRoom", 1);
// Receive cached messages on join
socket.on("message", (data) => {
console.log(data);
});
// Send message
socket.emit("sendMessage", 1, "Hello everyone!");
// Receive broadcast
socket.on("message", (data) => {
console.log(data);
// { id: 5, room_id: 1, user_id: 2, message: "Hello everyone!", created_at: "..." }
});// User 2 starts DM with User 5
socket.emit("joinDM", 5);
// Creates room: "dm:2:5"
// User 5 starts DM with User 2
socket.emit("joinDM", 2);
// Creates same room: "dm:2:5"
// Send message in DM room
socket.emit("sendMessage", "dm:2:5", "Hey, what's up?");Why Socket.io over raw WebSocket?
Raw WebSocket provides a persistent connection but no rooms, no events, no reconnection logic. Socket.io adds all of these out of the box, along with automatic fallback to long-polling for environments where WebSocket is blocked by proxies or firewalls.
Why verify JWT at connection time, not per message?
WebSocket is a persistent connection — unlike HTTP there are no repeated requests. The JWT is verified once during the handshake and the user ID is stored on socket.data.userId. All subsequent events trust that verified identity for the lifetime of the connection.
Why Redis for online status instead of PostgreSQL?
Online status changes on every connect and disconnect — potentially hundreds of times per minute per active user. Writing to PostgreSQL on every change would be prohibitively expensive. Redis operates in memory at sub-millisecond speeds and is purpose-built for ephemeral, high-frequency data.
Why TTL on online status keys?
If a user's browser crashes, no disconnect event fires on the server. Without TTL, the user's status would remain "online" indefinitely. A 30-second TTL forces the client to send periodic heartbeats to renew the key. If no heartbeat arrives, the key expires automatically and the status disappears — the same pattern used in production presence systems.
Why cache only the last 50 messages in Redis?
Users rarely scroll back more than 50 messages on room join. Caching all messages would consume unbounded Redis memory. By trimming the list to 50 entries on every insert, memory usage stays constant regardless of how many messages a room accumulates. Older messages remain safely in PostgreSQL.
Why use Math.min/max for DM room names?
A DM between User 2 and User 5 must always resolve to the same room regardless of who initiates. Without sorting, User 2 → User 5 would create dm:2:5 and User 5 → User 2 would create dm:5:2 — two separate rooms. Using Math.min/max guarantees the smaller ID always comes first, producing an identical room name from either direction.
Why store image URLs as messages instead of binary data?
Storing binary image data directly in PostgreSQL or Socket.io payloads would be impractical — databases are not designed for large binary objects and WebSocket frames have size limits. Instead, the image is uploaded via HTTP multipart to the server, saved to disk, and only the resulting URL is stored and broadcast via Socket.io.
- Passwords hashed with bcrypt (salt rounds: 10)
- JWT tokens expire after 30 days
- Socket connections verified at handshake via JWT middleware — unauthorized connections rejected before any events fire
- File type validation via MIME type — only
image/*accepted - File size capped at 2MB per upload
- Sensitive credentials managed via environment variables only
.envand.env.productionexcluded from version control
- Currently single-server (no horizontal scaling with Redis Pub/Sub)
- Presence tracking uses polling/TTL — can be optimized further with event-based sync
- Message history pagination not fully implemented beyond cached 50 messages
- Can be extended with load balancing and distributed socket handling
| Resource | Platform |
|---|---|
| API Server | Render |
| Database | Render PostgreSQL |
| Cache | Redis Cloud |
| Live URL | your-render-url.onrender.com |
Tirth Patel — Backend Developer
