Home / Articles / Designing Real-Time Chat Backends: Rooms, Persistence, and Scaling

This article is published in English.

Designing Real-Time Chat Backends: Rooms, Persistence, and Scaling

Learn how to architect a real-time chat backend using Socket.IO, PostgreSQL, and Redis, covering rooms, message persistence order, presence, and multi-server scaling.

2380 words

Real-time communication changes how a server and client relate to each other, moving beyond simple request-response into a world of WebSockets, rooms, message persistence, presence tracking, and Redis-based scaling.

Most APIs follow a predictable pattern:

Client
   ↓
HTTP Request
   ↓
Server
   ↓
HTTP Response

The client makes a request. The server sends back a response. That's the whole interaction.

But now think about what it would take to build something like:

  • WhatsApp
  • Slack
  • Discord
  • Live notification feeds
  • Online presence indicators
  • Typing indicators
  • Live dashboards
  • Multiplayer functionality

In these cases, you don't want the client repeatedly checking in with:

"Did anything change?"

Instead, you want the server itself to be able to announce:

"Something changed. Here's the update."

This is exactly the problem real-time communication solves.

1. HTTP vs Real-Time Communication

Traditional HTTP polling looks like this:

Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."

It works, but it wastes requests and bandwidth checking for updates that usually aren't there.

A persistent real-time connection behaves differently:

Client ←────────────→ Server
       connection
Server → New message
Server → User online
Server → Typing...
Server → Message read

Once the connection is open, the server can push events directly to the client whenever something happens.

WebSockets are a widely used technology for enabling this kind of connection. In the Node.js ecosystem, Socket.IO is a popular library built specifically for real-time communication.

2. A Simple Architecture

A minimal chat application might be structured like this:

                 ┌──────────────┐
                 │ Web / Mobile │
                 └──────┬───────┘
                        │
                    WebSocket
                        │
                        ▼
                 ┌──────────────┐
                 │   Node.js    │
                 │ Socket.IO    │
                 └──────┬───────┘
                        │
             ┌──────────┴──────────┐
             ▼                     ▼
       ┌───────────┐         ┌───────────┐
       │ PostgreSQL│         │   Redis   │
       │ Messages  │         │ Pub/Sub   │
       └───────────┘         └───────────┘

Each piece plays a distinct role.

Node.js + Socket.IO

Manages live connections and the events flowing through them.

PostgreSQL

Persists messages and conversation history.

Redis

Comes into play once you're running more than one instance of your application and need those instances to stay in sync for real-time events.

3. Setting Up Socket.IO

A basic server setup might look like this:

import { Server } from "socket.io";

const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL,
    credentials: true,
  },
});
io.on("connection", (socket) => {
  console.log("User connected:", socket.id);
  socket.on("disconnect", () => {
    console.log("User disconnected:", socket.id);
  });
});

Each time a client connects, Socket.IO establishes a dedicated socket for that connection, so every connected client gets its own socket instance.

4. Events Are the Core Concept

Rather than the request-based mindset of:

"Call this endpoint"

real-time systems are typically organized around named events:

"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"

For instance, the server might emit:

socket.emit("message-created", {
  id: message.id,
  text: message.text,
});

And the client listens for that same event:

socket.on("message-created", (message) => {
  console.log("New message:", message);
});

This shift toward an event-driven model is one of the fundamental ways real-time applications differ from conventional REST-based APIs.

5. Rooms Make Chat Systems Much Easier

Picture a one-on-one conversation:

User A
User B

You don't want to broadcast every message to all connected users, only to the ones actually involved in that conversation.

Socket.IO lets you group sockets into a room:

socket.join(`conversation:${conversationId}`);

Then, whenever a new message is created, you emit it to that specific room:

io.to(`conversation:${conversationId}`)
  .emit("message-created", message);

Only the sockets that joined that room will receive the event.

The same idea scales to group conversations. A room like:

conversation:123

might contain several participants:

User A
User B
User C
User D

and a single emitted message reaches everyone in that room at once.

6. Don't Save the Message After Broadcasting

This is a design choice worth being deliberate about.

A risky sequence would be:

Receive message
      ↓
Broadcast message
      ↓
Save to database

The problem: what happens if writing to the database fails after the message has already gone out? Users would have seen a message that was never actually saved, creating an inconsistency between what people see and what's stored.

A more reliable pattern is to persist first, then broadcast:

Client
  ↓
send-message
  ↓
Validate
  ↓
Save to PostgreSQL
  ↓
Database succeeds
  ↓
Broadcast event

In practice, that looks something like:

socket.on("send-message", async (data) => {
  const message = await saveMessage(data);

io.to(`conversation:${data.conversationId}`)
    .emit("message-created", message);
});

The exact durability guarantees you need will vary by application, but the underlying principle holds generally: how messages are persisted and delivered should be an intentional decision, not an afterthought.

7. Store Chat History in PostgreSQL

Building a system around real-time events doesn't mean everything should only exist in memory.

People expect that when they open a conversation the next day, their earlier messages are still there.

A simplified schema might look like this:

CREATE TABLE messages (
    id UUID PRIMARY KEY,
    conversation_id UUID NOT NULL,
    sender_id UUID NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

Then, whenever a user opens a conversation, you'd run something like:

SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;

At this point we've split the work into two distinct responsibilities:

Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history

Keeping these concerns separate matters a lot.

8. Add an Index for Conversation History

If your application regularly runs a query shaped like this:

WHERE conversation_id = ?
ORDER BY created_at DESC

then your database schema should be built with that pattern in mind.

For instance:

CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);

The point isn't to sprinkle indexes everywhere without thinking.

An index is only useful if it lines up with the queries your app is actually running.

And, echoing something covered before:

Always measure performance before and after making the change.

9. Online Presence Is Different From Message Storage

Say you want to display something like:

Mit
● Online

There's no need to persist something like:

user.is_online = true

inside PostgreSQL every single time a user connects.

Why not?

Because presence status changes constantly.

For most systems, a better fit is to store this kind of short-lived presence data in Redis instead.

For example:

online:user:123
TTL → 60 seconds

The client can send periodic heartbeats to signal it's still active.

Once those heartbeats stop arriving, the presence key simply expires on its own.

This keeps temporary connection state from being mistaken for permanent, database-worthy data.

10. Typing Indicators Are Even More Temporary

Take something like:

Mit is typing...

Does this need a row in PostgreSQL?

Definitely not.

It's purely transient.

A socket event is all it takes:

socket.to(roomId).emit("user-typing", {
  userId,
});

And once the user stops typing:

socket.to(roomId).emit("user-stopped-typing", {
  userId,
});

This points to a broader design principle:

Not everything your application tracks needs to live in a database.

A good test is whether the information needs to survive a server restart.

If it doesn't, some kind of ephemeral storage is probably the better fit.

11. The Problem With Multiple Node.js Servers

Here's where things start getting more complex.

Picture a setup with just one Node.js server:

Client
   ↓
Node.js

At that scale, everything works cleanly.

But then traffic increases.

Now the setup looks more like:

              Load Balancer
                /       \
               ↓         ↓
          Node.js A   Node.js B

User A ends up connected to Node.js A.

User B ends up connected to Node.js B.

Now User A sends a message.

How is Node.js B supposed to find out it needs to deliver that event to User B?

This is exactly the kind of problem that calls for a shared messaging layer between your server instances.

12. Redis Can Connect Multiple Instances

One common solution is Redis, paired with the Socket.IO Redis adapter.

Conceptually, it looks like this:

                Load Balancer
                 /         \
                ↓           ↓
          Node.js A     Node.js B
                \           /
                 \         /
                   Redis

With this in place, an event created on one server instance can be propagated across the others.

That's what lets your real-time layer scale past a single Node.js process.

It's worth being clear that Redis is not a replacement for PostgreSQL here.

The two tools serve different purposes.

PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination

13. Authentication Still Matters

Establishing a WebSocket connection doesn't automatically mean that connection can be trusted.

Authentication is still required.

A typical approach is for the client to connect while presenting some kind of token.

Before granting access to private conversations, the server has to validate that token.

Conceptually, the flow looks like:

Client
  ↓
Connection
  ↓
Authenticate
  ↓
Validate user
  ↓
Allow socket connection

Then, when a client tries to join a room such as:

conversation:123

the server needs to confirm that the authenticated user is actually a participant in that conversation.

Never simply accept:

socket.join(conversationId);

on the assumption that the client-provided ID can be trusted at face value.

14. Handle Disconnects

Connections dropping unexpectedly is a constant reality in real-time systems.

A user might:

  • Close their browser
  • Lose their Wi-Fi connection
  • Switch between networks
  • Let their phone go to sleep
  • Lose mobile signal
  • Force-quit the app

Because of this, your server needs logic like:

socket.on("disconnect", (reason) => {
  console.log("Disconnected:", reason);
});

Reconnection logic also needs consideration.

A brief five-second network drop shouldn't mean a user permanently loses access to real-time features.

This is part of why real-time systems generally demand more careful state management than a typical REST API does.

15. Real-Time Doesn't Require Everything to Ride on WebSockets

Here's another key takeaway.

There's no rule that says your whole application has to be rebuilt around WebSocket connections.

You can mix approaches:

REST API
+
WebSockets
+
PostgreSQL
+
Redis

For instance:

REST

Reach for REST when handling:

Login
Get conversation history
Create conversation
Upload files
Search messages

WebSocket

Reach for WebSocket events when handling:

New message
Typing indicator
Online status
Read receipts
Live notifications

This hybrid setup tends to keep things far simpler than trying to funnel everything through sockets.

16. Things to Keep in Mind for Production

Moving to real-time adds a new layer of concerns.

You'll need to account for:

Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance

And if you're building something closer to a full messaging product, add to that:

Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination

The scope of the problem expands fast.

That's exactly why the first version shouldn't try to solve everything at once.

17. A Reasonable Starting Point

For a smaller app, a sensible starting architecture looks like:

             Client
                │
                ▼
        ┌───────────────┐
        │    Node.js    │
        │  REST + WS    │
        └───────┬───────┘
                │
         ┌──────┴──────┐
         ▼             ▼
    PostgreSQL       Redis
    Messages         Cache /
    Users            Presence

You can grow from there once it's actually needed:

                 Load Balancer
                 /           \
                ▼             ▼
          Node.js A       Node.js B
                \             /
                 \           /
                    Redis
                      │
                      ▼
                 PostgreSQL

Keep the initial build simple. Measure how it performs under real usage. Only then scale the specific pieces that turn out to be genuine bottlenecks.

18. A Checklist for Real-Time Backends

Before treating a chat backend as production-ready, confirm:

[ ] Authentication implemented
[ ] Authorization for conversations
[ ] WebSocket connection handling
[ ] Room management
[ ] Message persistence
[ ] Message pagination
[ ] Reconnection handling
[ ] Duplicate message handling
[ ] Online/offline presence
[ ] Typing indicators
[ ] Rate limiting
[ ] Redis for multi-instance coordination
[ ] Logging
[ ] Monitoring
[ ] Database indexes
[ ] Load testing
[ ] Failure scenarios tested

Final Thought

From the outside, building a chat feature seems straightforward.

You type:

"Hello"

And someone else receives:

"Hello"

But hidden behind those two words is a whole set of engineering challenges:

Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling

That hidden complexity is precisely what makes real-time systems worth understanding well.

The main lesson worth keeping is this:

Resist the urge to build a massively distributed system on day one.

Begin with:

Node.js
+
Socket.IO
+
PostgreSQL

Get a feel for how this combination behaves under real conditions.

Bring in Redis and additional instances only once actual requirements make that step necessary.

Keep the first version minimal. Take the time to understand how the core pieces work together. Watch how the system holds up under real load. Only expand the parts of the setup that genuinely need more capacity.

That progression is how a basic chat feature turns into a dependable real-time backend.