Building a Real-Time Chat App with WebSockets, JWT & PostgreSQL

January 10, 2026 (7mo ago)

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:

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:

  1. User registers or logs in via REST API
  2. Server generates a JWT and stores it in the database
  3. Client opens a WebSocket connection with the token
  4. Server validates the token and establishes the real-time connection
  5. Messages flow bidirectionally over WebSocket
  6. 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, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
}

Now that malicious script becomes harmless text: &lt;script&gt;alert('gotcha')&lt;/script&gt;

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:

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 stop

This 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:

  1. 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
  2. Sticky sessions so WebSocket connections stay on the same server
  3. 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: free

One 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:

  1. Message persistence—currently, messages disappear when you refresh
  2. Socket.IO—its automatic reconnection would simplify the client code
  3. Redis for sessions—faster than PostgreSQL for token lookups
  4. Typing indicators—those little "User is typing..." messages
  5. 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:

The full source code is on GitHub if you want to dig deeper. Happy building!