Real-time chat is one of those features that looks simple in a demo and falls apart the moment real users touch it. A basic "send message, receive message" loop is maybe 30 lines of code. A chat feature that survives dropped connections, reconnects gracefully, scales past a few thousand concurrent users, and doesn't silently lose messages is a completely different engineering problem.
I recently worked through this build with a small team at a social media app development company, and this post covers the actual architecture we landed on — not the toy version you'll find in most WebSocket tutorials, but the parts that mattered once real users started hammering on it.
Why WebSockets (and Not Polling or SSE)
Quick context for why WebSockets specifically, since this comes up every time:
HTTP polling — simplest to build, worst for real-time. You're either polling too fast (wasting server resources) or too slow (laggy chat). Dead on arrival for anything chat-like.
Server-Sent Events (SSE) — great for one-directional streams (notifications, live feeds), but chat is inherently bidirectional. You'd need a separate channel for sending messages, which adds complexity without much benefit over just using WebSockets.
WebSockets — full-duplex, persistent connection, low overhead per message once the connection is established. The right tool here, full stop.
The tradeoff is that WebSockets require you to manage connection state yourself — reconnection logic, presence tracking, message ordering — none of which HTTP gives you for free. That's where most tutorials stop being useful, so that's where this one starts.
Core Architecture
At a high level, our stack looked like this:
Client (React Native / React)
↕ WebSocket connection
WebSocket Gateway (Node.js + ws / Socket.IO)
↕
Redis Pub/Sub (message broadcast across server instances)
↕
Message Persistence Layer (PostgreSQL)
The critical piece most beginner tutorials skip: you cannot run a single WebSocket server instance in production and call it done. The moment you scale horizontally — which any social app needs to eventually — you hit a hard problem: User A is connected to Server 1, User B is connected to Server 2, and they need to message each other. Server 1 has no idea Server 2 exists unless you build a bridge between them.
Redis Pub/Sub solves this cleanly. Every server instance subscribes to relevant channels, and when a message arrives on Server 1, it publishes to Redis, which broadcasts to Server 2, which forwards it to User B's socket connection.
Setting Up the WebSocket Gateway
We used Socket.IO over raw ws for one specific reason: automatic reconnection with exponential backoff and fallback transport handling (falling back to long-polling if WebSocket connection fails, which still happens on some corporate/institutional networks). Here's the server setup, trimmed to the essentials:
javascript
const { Server } = require("socket.io");
const { createAdapter } = require("@socket.io/redis-adapter");
const { createClient } = require("redis");
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_ORIGIN },
adapter: createAdapter(pubClient, subClient),
});
io.use(authenticateSocket); // JWT verification middleware
io.on("connection", (socket) => {
const userId = socket.data.userId;
socket.join(user:${userId});
socket.on("send_message", async (payload) => {
const message = await persistMessage(payload, userId);
io.to(user:${payload.recipientId}).emit("new_message", message);
socket.emit("message_ack", { tempId: payload.tempId, messageId: message.id });
});
socket.on("disconnect", () => {
updatePresence(userId, "offline");
});
});
Two details in here matter more than they look:
The redis-adapter line is what makes horizontal scaling actually work — without it, io.to(...) only reaches sockets connected to the same server instance.
The message_ack event exists because clients need to know their message actually made it to the server and got persisted, not just that it left the client. This is the foundation for the "sending → sent → delivered → read" status indicators every chat app has trained users to expect.
Message Persistence: Don't Trust the Socket Layer
A mistake we made early on: treating the WebSocket layer as the source of truth. It isn't, and it shouldn't be. Sockets disconnect. Servers restart. Messages sent during a brief connection drop need somewhere durable to land.
The pattern that worked:
Client sends message with a client-generated tempId (UUID)
Server persists to PostgreSQL before broadcasting
Server broadcasts, replacing tempId with the real messageId in the ack
Client reconciles local state using tempId, replacing the optimistic UI message with the confirmed one
sql
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id),
sender_id UUID NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'sent',
created_at TIMESTAMPTZ DEFAULT now(),
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ
);
CREATE INDEX idx_messages_conversation_created
ON messages (conversation_id, created_at DESC);
That index matters more than it looks — chat history queries are almost always "give me the last N messages in this conversation, ordered by time," and without a composite index on (conversation_id, created_at), that query degrades badly once conversations accumulate thousands of messages.
Handling Reconnection Without Losing Messages
This is the part almost no tutorial covers, and it's the difference between a chat feature that feels reliable and one that quietly drops messages during flaky connectivity — which, on mobile, is constant.
The pattern: on reconnect, the client sends the timestamp of its last known message, and the server replays anything missed.
javascript
socket.on("connect", async () => {
const lastMessageTimestamp = await getLastSyncedTimestamp();
socket.emit("sync_request", { since: lastMessageTimestamp });
});
socket.on("sync_response", (missedMessages) => {
missedMessages.forEach((msg) => appendToLocalStore(msg));
});
Server-side, sync_request triggers a straightforward query against the persistence layer rather than relying on anything held in memory — because whatever was in memory on the old connection is gone. This single feature eliminated the majority of "message just disappeared" bug reports we got during beta testing.
Presence and Typing Indicators
These feel like nice-to-haves until users notice their absence. Both are cheap to implement once the Redis layer is in place:
javascript
socket.on("typing_start", ({ conversationId }) => {
socket.to(conversation:${conversationId}).emit("user_typing", { userId });
});
socket.on("typing_stop", ({ conversationId }) => {
socket.to(conversation:${conversationId}).emit("user_stopped_typing", { userId });
});
For presence (online/offline/last-seen), we stored state in Redis rather than the primary database — presence changes constantly and doesn't need durability, so writing it to Postgres on every connect/disconnect would be wasted I/O.
javascript
async function updatePresence(userId, status) {
await redisClient.set(presence:${userId}, status, { EX: 60 });
}
The EX: 60 (60-second expiry) is a deliberate choice: if a client disconnects ungracefully (app killed, network drops without a clean close), the presence key expires on its own rather than showing the user as permanently "online."
Scaling Considerations We Ran Into
A few things that only became visible once we load-tested with simulated concurrent users:
Connection limits per server instance — a single Node process comfortably handles tens of thousands of idle WebSocket connections, but active message throughput (not connection count) is usually the real bottleneck. Profile before assuming you need more servers than you actually do.
Redis Pub/Sub doesn't guarantee delivery — if a subscriber is briefly disconnected, published messages during that window are lost. This is exactly why persistence-first (writing to Postgres before broadcasting) matters — Pub/Sub is for real-time delivery, not durability.
Message ordering under concurrent sends — with multiple server instances, two messages sent near-simultaneously to the same conversation can arrive out of order at the recipient. We handled this by sorting on created_at (with a monotonic sequence tiebreaker) client-side rather than trusting arrival order.
Wrapping Up
The gap between "WebSockets tutorial" and "production chat feature" is almost entirely in the parts covered here: persistence-first design, reconnection sync, horizontal scaling via Redis, and graceful presence handling. None of it is exotic engineering — it's mostly about not trusting the socket connection to be the single source of truth for anything that matters.
If you're scoping this for a real product rather than a demo, budget real time for the reconnection and persistence layers specifically — that's where the actual engineering effort goes, and it's usually the part that gets underestimated in early planning with a social media app development company or in-house team alike.
Top comments (0)