WebSockets com Socket.io: Chat em Tempo Real do Zero
WebSocket é o protocolo certo para comunicação bidirecional em tempo real. HTTP polling funciona para dados que atualizam a cada minuto; para mensagens instantâneas, você precisa de WebSocket.
Arquitetura do sistema
Cliente A ──┐
Cliente B ──┼── Socket.io Server ── Redis Adapter ── Socket.io Server
Cliente C ──┘ (segunda instância)O Redis Adapter permite escalar horizontalmente — mensagens emitidas em qualquer instância chegam a todos os clientes.
Servidor: setup completo
// server.js
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
pingTimeout: 60000,
pingInterval: 25000,
});
// Redis para múltiplas instâncias
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));Autenticação no handshake
// Middleware executado antes da conexão
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Token obrigatório'));
try {
const user = await verifyJWT(token);
socket.user = user; // disponível em todos os handlers
next();
} catch {
next(new Error('Token inválido'));
}
});Gerenciamento de salas e presença
io.on('connection', (socket) => {
console.log(`${socket.user.name} conectou — ${socket.id}`);
// Entrar em uma sala
socket.on('join:room', async ({ roomId }) => {
await socket.join(roomId);
// Notifica os outros na sala
socket.to(roomId).emit('user:joined', {
userId: socket.user.id,
name: socket.user.name,
});
// Envia histórico das últimas 50 mensagens
const history = await Message.findAll({
where: { roomId },
order: [['createdAt', 'DESC']],
limit: 50,
});
socket.emit('room:history', history.reverse());
});
// Enviar mensagem
socket.on('message:send', async ({ roomId, content }) => {
// Valida que o usuário está na sala
const rooms = socket.rooms;
if (!rooms.has(roomId)) return;
const message = await Message.create({
roomId,
userId: socket.user.id,
content: sanitize(content),
});
// Broadcast para todos na sala (inclusive quem enviou)
io.to(roomId).emit('message:new', {
id: message.id,
content: message.content,
author: socket.user.name,
createdAt: message.createdAt,
});
});
// Indicador "digitando..."
socket.on('typing:start', ({ roomId }) => {
socket.to(roomId).emit('typing:update', {
userId: socket.user.id,
name: socket.user.name,
isTyping: true,
});
});
socket.on('typing:stop', ({ roomId }) => {
socket.to(roomId).emit('typing:update', {
userId: socket.user.id,
name: socket.user.name,
isTyping: false,
});
});
// Desconexão
socket.on('disconnect', (reason) => {
console.log(`${socket.user.name} desconectou — ${reason}`);
// Notifica todas as salas que o usuário estava
socket.rooms.forEach((roomId) => {
socket.to(roomId).emit('user:left', { userId: socket.user.id });
});
});
});Cliente React
// hooks/useChat.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
interface Message {
id: string;
content: string;
author: string;
createdAt: string;
}
export function useChat(roomId: string) {
const socketRef = useRef<Socket | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const [connected, setConnected] = useState(false);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const token = localStorage.getItem('token');
socketRef.current = io(process.env.NEXT_PUBLIC_API_URL!, {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
});
const socket = socketRef.current;
socket.on('connect', () => {
setConnected(true);
socket.emit('join:room', { roomId });
});
socket.on('disconnect', () => setConnected(false));
socket.on('room:history', (history: Message[]) => {
setMessages(history);
});
socket.on('message:new', (msg: Message) => {
setMessages((prev) => [...prev, msg]);
});
socket.on('typing:update', ({ name, isTyping }: { name: string; isTyping: boolean }) => {
setTypingUsers((prev) =>
isTyping ? [...new Set([...prev, name])] : prev.filter((n) => n !== name)
);
});
return () => { socket.disconnect(); };
}, [roomId]);
const sendMessage = useCallback((content: string) => {
socketRef.current?.emit('message:send', { roomId, content });
}, [roomId]);
const sendTyping = useCallback(() => {
socketRef.current?.emit('typing:start', { roomId });
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
socketRef.current?.emit('typing:stop', { roomId });
}, 2000);
}, [roomId]);
return { messages, typingUsers, connected, sendMessage, sendTyping };
}Lidando com reconexão
O Socket.io reconecta automaticamente, mas você precisa re-entrar nas salas:
socket.on('connect', () => {
// Se já estava em salas antes da desconexão, rejoin
if (currentRoomId) {
socket.emit('join:room', { roomId: currentRoomId });
}
});Escalando com Redis em produção
Com um único servidor você aguenta ~10k conexões simultâneas. Para mais, use clustering com o Redis Adapter — cada instância publica no Redis e todas recebem, transparente para o código da aplicação.
O ponto crítico é monitorar o lag do Redis: se o pub/sub adicionar mais de 10ms de latência, o “tempo real” começa a parecer atrasado. Use LATENCY HISTORY event no Redis para monitorar isso.