Backend· 6 min read

Real-Time Chat at Scale with Socket.IO and Redis Pub/Sub

Build real-time chat that scales with Socket.IO and Redis: rooms, the Redis adapter, delivery and ordering, presence, reconnection and React Native.

By

Full-Stack, Mobile & AI Engineer

A chat demo takes an afternoon: a Socket.IO server, a room, emit on one side and on on the other. A chat product takes much longer, because real users are spread across many servers, lose signal in lifts, open the app on two devices, and expect every message exactly once, in order, within a heartbeat.

I've scaled real-time chat and collaboration to 100K+ concurrent users with Socket.IO event streaming and Redis pub/sub, with sub-200ms global message delivery. This guide covers the architecture that gets you there: Socket.IO rooms, scaling across servers with the Redis adapter, persistence, delivery and ordering, presence, reconnection, and the React Native client side.

Key takeaways

  • Rooms map to conversations; emit to a room and every member's connection receives the message.
  • Multiple servers need the Redis adapter so a message emitted on one node reaches sockets on every node.
  • Persist first, then broadcast, with server-assigned message IDs and per-conversation sequence numbers.
  • Clients must recover missed messages after reconnecting by fetching everything after their last sequence number.
  • Presence and typing are ephemeral: keep them in Redis with expiry, not in your main database.

The core: connections, rooms and events

When a user connects, authenticate them in the handshake, then join the rooms for their conversations:

import { Server } from "socket.io";

const io = new Server(httpServer, { cors: { origin: ALLOWED_ORIGINS } });

io.use(async (socket, next) => {
  const user = await verifyToken(socket.handshake.auth.token);
  if (!user) return next(new Error("unauthorized"));
  socket.data.userId = user.id;
  next();
});

io.on("connection", async (socket) => {
  const conversations = await listConversationIds(socket.data.userId);
  socket.join(conversations.map((id) => `conv:${id}`));

  socket.on("message:send", async (msg, ack) => {
    const saved = await saveMessage(socket.data.userId, msg); // assigns id + sequence
    io.to(`conv:${saved.conversationId}`).emit("message:new", saved);
    ack({ id: saved.id, seq: saved.seq });
  });
});

io.emit vs socket.emit (and friends)

CallWho receives it
socket.emit(...)Only this one connection
io.emit(...)Every connected client on every node
socket.broadcast.emit(...)Everyone except this connection
io.to(room).emit(...)Everyone in that room (a conversation)
socket.to(room).emit(...)Everyone in the room except the sender

For chat, you almost always want io.to(room).emit: everyone in the conversation, including the sender's other devices.

Scaling across servers with Redis

One Node.js process can hold many connections, but a product needs several servers for capacity and resilience. A socket connected to server A can't directly receive a message emitted on server B, so the servers need a shared channel:

  • The Socket.IO Redis adapter publishes broadcasts through Redis pub/sub, so every node delivers to its local sockets in the room.
  • Sticky sessions are needed at the load balancer if clients may fall back to HTTP long-polling; using WebSocket-only transport from mobile clients avoids that requirement.
  • Scale Redis deliberately: a dedicated instance (or cluster) for pub/sub keeps chat traffic from competing with your cache.

Persistence, delivery and ordering

Real-time delivery is fast but not reliable on its own, because connections drop. Make the database the source of truth:

  1. Persist the message first, assigning a server ID and a per-conversation sequence number.
  2. Broadcast after persisting, including the ID and sequence.
  3. Acknowledge to the sender with the saved ID, so the client can replace its pending bubble.
  4. Deduplicate on the client by message ID, since a message can arrive both live and in a catch-up fetch.
  5. Order by sequence, not arrival time, because network timing varies.

Accept a client-generated ID on send as well, so a sender's retry after a timeout doesn't create a duplicate message.

Reconnection and missed messages

Mobile connections drop constantly. On reconnect, the client asks the API for every message after the last sequence number it has for each active conversation, merges them, and resumes live updates. Socket.IO also offers connection state recovery for short disconnections, but a catch-up fetch remains the reliable safety net for longer gaps.

From the field: I scaled real-time chat and collaboration to 100K+ concurrent users with Socket.IO event streaming and Redis pub/sub, achieving sub-200ms global message delivery, and built real-time messaging features into products like Spoxio and Honeydew. At that scale, the work is less about sockets and more about persistence, ordering and recovery.

Presence, typing and read receipts

  • Presence ("online now"): store a key per user in Redis with a short expiry, refreshed by heartbeats; publish changes to interested rooms.
  • Typing indicators: purely ephemeral events with throttling; never persist them.
  • Read receipts: persist the last read sequence per user per conversation, and broadcast updates to the room.

Large rooms and group chats

Direct messages and small groups are easy to fan out. Large rooms (a community channel, a live event chat with thousands of viewers) are a different load profile, because every message is delivered to every member's connection. Practical limits help: throttle typing indicators or disable them above a certain size, batch presence updates, paginate history aggressively, and consider sending large-room updates as lightweight notifications that clients fetch in pages. If one kind of room dominates load, giving it its own Socket.IO namespace or service keeps it from slowing down everyone's direct messages.

The React Native client

import { io } from "socket.io-client";

export const socket = io(API_URL, {
  transports: ["websocket"],
  auth: (cb) => cb({ token: getAccessToken() }),
});

socket.on("message:new", (msg) => upsertMessageLocally(msg)); // dedupe by id
socket.on("connect", () => catchUpMissedMessages());

Can you use Socket.IO with React Native and Expo? Yes: socket.io-client is plain JavaScript, so it runs in Expo Go and development builds without native code. Keep the client and server on the same major version, and prefer the WebSocket transport, as above. Using Socket.IO in React Native works well with this setup. Disconnect cleanly when the app goes to the background for long periods, reconnect on foreground, and rely on push notifications to reach users whose app isn't connected.

Security and abuse

Authenticate in the handshake, check room membership on the server before joining or emitting, validate every payload, rate-limit sends per user, and run moderation on content where your product needs it. In social and dating apps especially, safety tooling such as reporting, blocking and content checks is part of the core chat feature, not an add-on.

FAQ

How do I scale Socket.IO to multiple servers?

Use the Socket.IO Redis adapter so broadcasts are shared across nodes through Redis pub/sub, and either configure sticky sessions at the load balancer or use WebSocket-only transport. Persist messages to a database so delivery survives reconnects and server restarts.

What's the difference between io.emit and socket.emit?

socket.emit sends to a single connection. io.emit sends to every connected client across all nodes. For chat, use io.to(room).emit to send to everyone in a conversation, or socket.to(room).emit to exclude the sender's current connection.

Can Socket.IO handle 100,000 concurrent users?

Yes, with horizontal scaling: several Node.js servers behind a load balancer, the Redis adapter for cross-node broadcasts, efficient persistence, and careful client reconnection. The constraints at that scale are usually database writes, Redis capacity and fan-out to large rooms.

How do I use Socket.IO in React Native?

Use socket.io-client with the WebSocket transport, pass an auth token in the handshake, deduplicate incoming messages by ID, fetch missed messages after reconnecting, and use push notifications for users whose app isn't currently connected.

Next steps

If you're building chat or real-time collaboration and want it to hold up beyond the demo, I've scaled it to 100K+ concurrent users and can help you design and build it. Get in touch, or see how I work as a full-stack developer.

Sources

Bhuwanesh Sisodia

Bhuwanesh Sisodia

Full-Stack, Mobile & AI Engineer

Bhuwanesh Sisodia is a Full-Stack, Mobile & AI Engineer based in Mumbai, with 7+ years of experience and 50+ apps shipped to the App Store and Google Play. He works independently with startups and product teams worldwide.

About meGitHubLinkedInX

Related