All parts · Chat at Scale
Real-Time Chat at Scale — Part 02Backend

Your First Chat Server with Socket.IO

Rebuild Relay on Socket.IO with fully typed events, conversation rooms, acknowledgements with timeouts, payload validation and a clean React hook.

Syed Wajeeh Haider
Syed Wajeeh Haider
Full Stack Software Engineer
Sep 28, 20267 min read
#Socket.IO#TypeScript#React#Node.js

Part 1 ended with a broadcast chat on raw WebSockets and a list of everything it was missing. Socket.IO fills most of that list: named events, rooms, acknowledgements, automatic reconnection and a fallback transport. In this part we rebuild Relay on Socket.IO 4, keep it fully typed from server to client, and set up the structure the rest of the series grows from.

What Socket.IO is, and what it is not

Socket.IO is a protocol on top of WebSockets (and HTTP long polling), not a WebSocket library. A plain new WebSocket() cannot talk to a Socket.IO server and a Socket.IO client cannot talk to a plain WebSocket server. In exchange for that lock-in you get a message envelope with event names, multiplexed namespaces, acknowledgement IDs, heartbeats, reconnection with backoff and, crucially for part 7, adapters that let many servers act as one.

bash
mkdir -p relay/server relay/client
cd relay/server
npm init -y
npm i socket.io zod
npm i -D typescript tsx @types/node

cd ../client
npm create vite@latest . -- --template react-ts
npm i socket.io-client

Define the events once, share them everywhere

The biggest quality-of-life win in Socket.IO 4 is typed events. You describe every event each side can send, and both the server and the client get autocompletion and type errors for wrong payloads. Put these types in a small shared module that both projects import.

ts
export type Message = {
  id: string;            // server-assigned, time-sortable
  clientId: string;      // client-generated UUID, used for idempotency
  conversationId: string;
  senderId: string;
  body: string;
  createdAt: string;     // ISO timestamp
};

export type SendPayload = Pick<Message, 'clientId' | 'conversationId' | 'body'>;

export type Ack<T> = { ok: true; data: T } | { ok: false; error: string };

export interface ClientToServerEvents {
  'conversation:join': (conversationId: string, ack: (res: Ack<null>) => void) => void;
  'message:send': (payload: SendPayload, ack: (res: Ack<Message>) => void) => void;
  'typing:start': (conversationId: string) => void;
  'typing:stop': (conversationId: string) => void;
}

export interface ServerToClientEvents {
  'message:new': (message: Message) => void;
  'typing:start': (e: { conversationId: string; userId: string }) => void;
  'typing:stop': (e: { conversationId: string; userId: string }) => void;
}

export interface InterServerEvents {}

export interface SocketData {
  userId: string;
}
shared/events.ts

Every client-to-server event that needs a reply takes an ack callback as its last argument. The Ack<T> union forces handlers to report failures explicitly instead of silently dropping them, which pays off in part 5 when we build retries.

The server

ts
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { Server } from 'socket.io';
import { z } from 'zod';
import type {
  ClientToServerEvents,
  InterServerEvents,
  Message,
  ServerToClientEvents,
  SocketData,
} from '../../shared/events';

const httpServer = createServer((req, res) => {
  if (req.url === '/healthz') return res.writeHead(200).end('ok');
  res.writeHead(404).end();
});

const io = new Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>(
  httpServer,
  {
    cors: { origin: ['http://localhost:5173'], credentials: true },
    maxHttpBufferSize: 64 * 1024, // default is 1 MB; chat messages are tiny
    pingInterval: 25_000,
    pingTimeout: 20_000,
  },
);

const sendSchema = z.object({
  clientId: z.string().uuid(),
  conversationId: z.string().min(1).max(64),
  body: z.string().trim().min(1).max(4000),
});

const room = (conversationId: string) => `conv:${conversationId}`;

io.on('connection', (socket) => {
  // Temporary identity until part 3 adds real authentication.
  socket.data.userId = String(socket.handshake.auth.userId ?? 'anonymous');

  socket.on('conversation:join', async (conversationId, ack) => {
    await socket.join(room(conversationId));
    ack({ ok: true, data: null });
  });

  socket.on('message:send', (payload, ack) => {
    const parsed = sendSchema.safeParse(payload);
    if (!parsed.success) return ack({ ok: false, error: 'invalid_payload' });

    const message: Message = {
      id: randomUUID(), // replaced by a time-sortable ID in part 4
      ...parsed.data,
      senderId: socket.data.userId,
      createdAt: new Date().toISOString(),
    };

    // Everyone else in the room gets the event; the sender gets the ack.
    socket.to(room(message.conversationId)).emit('message:new', message);
    ack({ ok: true, data: message });
  });

  socket.on('typing:start', (conversationId) => {
    socket.to(room(conversationId)).emit('typing:start', { conversationId, userId: socket.data.userId });
  });

  socket.on('typing:stop', (conversationId) => {
    socket.to(room(conversationId)).emit('typing:stop', { conversationId, userId: socket.data.userId });
  });

  socket.on('disconnect', (reason) => {
    console.log(`${socket.id} disconnected: ${reason}`);
  });
});

httpServer.listen(3000, () => console.log('Relay on :3000'));
server/src/index.ts

Rooms and the four ways to emit

A room is just a named set of sockets that lives on the server. Clients never see rooms directly and cannot join one without the server calling socket.join(). Relay uses one room per conversation, conv:<conversationId>, and in part 3 adds a personal room per user, user:<userId>, so we can reach every tab and device a user has open.

Choosing the right emit is the difference between a working chat and one that shows duplicate messages:

  • —socket.emit(event, data) sends to this one connection only.
  • —socket.to(room).emit(...) sends to everyone in the room except this socket.
  • —io.to(room).emit(...) sends to everyone in the room, including the sender.
  • —socket.broadcast.emit(...) sends to every connected socket except this one. Almost never what you want in chat.

For message:send, Relay uses socket.to(room) for everyone else and returns the saved message to the sender through the ack. The sender already rendered the message optimistically, so the ack only needs to swap the temporary copy for the confirmed one. If a user has the same conversation open in two tabs, the second tab is a different socket and still receives message:new.

Acknowledgements with timeouts

An ack callback is Socket.IO's request/response mechanism. The client attaches a callback, the server calls it, and the result travels back on the same connection. On its own an ack can wait forever if the server never answers, so always pair it with a timeout. Since v4.6 there is also emitWithAck, which returns a promise.

ts
// Callback style: err is set if no ack arrives within 5 seconds.
socket.timeout(5000).emit('message:send', payload, (err, res) => {
  if (err) return markFailed(payload.clientId);
  if (!res.ok) return showError(res.error);
  confirm(res.data);
});

// Promise style: rejects on timeout.
try {
  const res = await socket.timeout(5000).emitWithAck('message:send', payload);
  if (res.ok) confirm(res.data);
} catch {
  markFailed(payload.clientId);
}
Callback style and promise style

A timeout does not mean the server failed. The message may have been saved and the ack lost on the way back. That is exactly why every message carries a clientId: retrying with the same ID must be safe, and part 4 makes the database enforce it.

A useChat hook for React

On the client, keep a single socket per app and wrap the conversation logic in a hook. The two rules that prevent most bugs are: create the socket once outside React's render cycle, and remove every listener you add in the effect cleanup, or you will get each message twice after a hot reload or a re-render.

ts
import { io, type Socket } from 'socket.io-client';
import type { ClientToServerEvents, ServerToClientEvents } from '../../shared/events';

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
  import.meta.env.VITE_RELAY_URL ?? 'http://localhost:3000',
  {
    autoConnect: false,
    transports: ['websocket', 'polling'],
    reconnectionDelay: 1000,
    reconnectionDelayMax: 10_000,
  },
);
client/src/socket.ts
ts
import { useCallback, useEffect, useState } from 'react';
import { socket } from './socket';
import type { Message } from '../../shared/events';

type LocalMessage = Message & { status: 'pending' | 'sent' | 'failed' };

export function useChat(conversationId: string, userId: string) {
  const [messages, setMessages] = useState<LocalMessage[]>([]);
  const [connected, setConnected] = useState(socket.connected);

  useEffect(() => {
    const join = () => socket.emit('conversation:join', conversationId, () => {});
    const onConnect = () => {
      setConnected(true);
      join(); // rooms are per connection: rejoin after every reconnect
    };
    const onDisconnect = () => setConnected(false);
    const onMessage = (m: Message) => {
      if (m.conversationId !== conversationId) return;
      setMessages((prev) => [...prev, { ...m, status: 'sent' }]);
    };

    socket.on('connect', onConnect);
    socket.on('disconnect', onDisconnect);
    socket.on('message:new', onMessage);

    if (socket.connected) join();
    else socket.connect();

    return () => {
      socket.off('connect', onConnect);
      socket.off('disconnect', onDisconnect);
      socket.off('message:new', onMessage);
    };
  }, [conversationId]);

  const send = useCallback(
    async (body: string) => {
      const clientId = crypto.randomUUID();
      const optimistic: LocalMessage = {
        id: clientId,
        clientId,
        conversationId,
        senderId: userId,
        body,
        createdAt: new Date().toISOString(),
        status: 'pending',
      };
      setMessages((prev) => [...prev, optimistic]);

      const settle = (next: Partial<LocalMessage>) =>
        setMessages((prev) => prev.map((m) => (m.clientId === clientId ? { ...m, ...next } : m)));

      try {
        const res = await socket.timeout(5000).emitWithAck('message:send', { clientId, conversationId, body });
        settle(res.ok ? { ...res.data, status: 'sent' } : { status: 'failed' });
      } catch {
        settle({ status: 'failed' });
      }
    },
    [conversationId, userId],
  );

  return { messages, send, connected };
}
client/src/useChat.ts

Reconnection: what survives and what does not

Socket.IO reconnects automatically with exponential backoff and randomisation, so a flaky network recovers without any code from you. What it does not do by default is restore state. A reconnect is a brand new connection with a new socket.id on the server, and the new socket belongs to no rooms. That is why the hook above rejoins inside the connect handler rather than once on mount.

Messages sent while a user was offline are also gone as far as the socket is concerned. For now, a reconnecting client simply misses them. Part 4 stores messages in Postgres and part 5 uses a cursor to fetch everything that arrived during the gap, which is the only reliable way to close it.

Key takeaways

  • —Socket.IO is its own protocol on top of WebSockets, adding events, rooms, acks, reconnection and adapters.
  • —Define event types once and share them; both server and client get compile-time checks.
  • —Validate every payload at runtime with zod and cap size with maxHttpBufferSize.
  • —Use socket.to(room) for everyone else and the ack for the sender to avoid duplicates.
  • —Always put a timeout on acks, and remember a timeout does not mean the write failed.
  • —Rooms do not survive reconnects; rejoin in the connect handler.

Next up, part 3: Authenticating WebSocket Connections, where we replace the fake user ID with verified JWTs and make sure users can only join conversations they belong to.

The series — Real-Time Chat at Scale
(10)Let's talk

Ready to scaleyour application?

Stop worrying about technical debt. I can help you build a reliable, high-performance product from the ground up, tailored exactly to your business needs.

Actively open for new opportunities
Get in touch
Local time
--:-- PKT
© 2026 Syed Wajeeh Haider. All rights reserved.