Socket.IO do Zero: Chat Completo com Salas, Typing Indicator e Histórico de Mensagens

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema com tutoriais de chat
A maioria dos tutoriais de Socket.IO para no "Hello World": um emit no cliente, um on no servidor, e pronto. O resultado é um chat que perde mensagens quando o usuário recarrega a página, não separa conversas por sala, e mostra "fulano está digitando" para todos os usuários conectados ao servidor, independente de onde estão.
Este post constrói um chat que resolve esses três problemas de verdade: salas isoladas com controle de entrada e saída, typing indicator com debounce que não inunda o servidor de eventos, e histórico persistido em PostgreSQL via Prisma. O código roda, tem tipos, e você pode copiar e adaptar.
Arquitetura do servidor com Express e Socket.IO
O servidor usa Express como base HTTP e Socket.IO como camada de WebSocket. A separação importa: Express serve o health check e qualquer endpoint REST que você precise, enquanto Socket.IO cuida exclusivamente da comunicação bidirecional.
// src/server.ts
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { PrismaClient } from "@prisma/client";
const app = express();
const httpServer = createServer(app);
const prisma = new PrismaClient();
// cors configurado para o domínio do frontend; "*" só em dev
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_ORIGIN ?? "http://localhost:3000",
methods: ["GET", "POST"],
},
// pingTimeout alto evita desconexões falsas em redes móveis
pingTimeout: 30_000,
pingInterval: 25_000,
});
app.get("/health", (_req, res) => {
res.json({ status: "ok", connections: io.engine.clientsCount });
});
const PORT = Number(process.env.PORT) || 4000;
httpServer.listen(PORT, () => {
console.log(`Server listening on :${PORT}`);
});
export { io, prisma };Se você precisa escalar para múltiplas instâncias, o Socket.IO sozinho não resolve: cada processo Node.js mantém seu próprio mapa de sockets. A solução padrão é o adapter do Redis, que sincroniza eventos entre processos via Pub/Sub.
Schema do banco: mensagens e salas
O modelo é simples de propósito. Uma tabela de salas, uma de mensagens com foreign key para a sala, e o username como string (sem autenticação neste escopo, mas você pode integrar com NextAuth.js quando precisar).
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Room {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
messages Message[]
}
model Message {
id String @id @default(cuid())
content String
username String
roomId String
room Room @relation(fields: [roomId], references: [id])
createdAt DateTime @default(now())
@@index([roomId, createdAt]) // índice composto para paginação por sala
}O índice composto [roomId, createdAt] é o que permite buscar "as últimas 50 mensagens da sala X" sem full scan. Sem ele, a query de histórico degrada linearmente com o volume de mensagens. Se você quer entender mais sobre modelagem de dados com Prisma, o post sobre API REST com Fastify e Prisma cobre o setup completo.
Tipos compartilhados entre cliente e servidor
Definir os tipos dos eventos em um arquivo compartilhado evita o problema clássico de emitir "mesage" (com typo) no cliente e escutar "message" no servidor.
// src/types/events.ts
export interface ServerToClientEvents {
"message:new": (message: MessagePayload) => void;
"room:joined": (data: { roomId: string; username: string }) => void;
"room:left": (data: { roomId: string; username: string }) => void;
"typing:update": (data: { username: string; isTyping: boolean }) => void;
"message:history": (messages: MessagePayload[]) => void;
"error:custom": (data: { message: string }) => void;
}
export interface ClientToServerEvents {
"room:join": (data: { roomId: string; username: string }) => void;
"room:leave": (data: { roomId: string }) => void;
"message:send": (data: { content: string }) => void;
"typing:start": () => void;
"typing:stop": () => void;
"message:loadHistory": (data: { cursor?: string; limit?: number }) => void;
}
export interface MessagePayload {
id: string;
content: string;
username: string;
roomId: string;
createdAt: string;
}O Socket.IO aceita esses tipos como generics do Server e do Socket, e o TypeScript passa a reclamar se você emitir um evento que não existe ou com payload errado. Essa é a diferença entre "funciona" e "funciona e não quebra no próximo refactor".
Handlers de conexão: salas, mensagens e typing
Aqui mora a lógica real. Cada socket que conecta precisa de um roomId e um username associados. Guardo isso em um Map no servidor porque o Socket.IO não persiste metadata customizada entre eventos de forma confiável no objeto socket.data em versões mais antigas.
// src/handlers/chat.ts
import type { Server, Socket } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
MessagePayload,
} from "../types/events";
import { PrismaClient } from "@prisma/client";
type IOServer = Server<ClientToServerEvents, ServerToClientEvents>;
type IOSocket = Socket<ClientToServerEvents, ServerToClientEvents>;
// metadata por socket: evita depender de socket.data que não é tipado por padrão
const socketMeta = new Map<string, { roomId: string; username: string }>();
export function registerChatHandlers(io: IOServer, socket: IOSocket, prisma: PrismaClient) {
socket.on("room:join", async ({ roomId, username }) => {
if (!roomId || !username) {
socket.emit("error:custom", { message: "roomId e username são obrigatórios" });
return;
}
// garante que a sala existe no banco antes de entrar
await prisma.room.upsert({
where: { name: roomId },
update: {},
create: { name: roomId },
});
// sai de qualquer sala anterior antes de entrar na nova
const previous = socketMeta.get(socket.id);
if (previous) {
socket.leave(previous.roomId);
io.to(previous.roomId).emit("room:left", {
roomId: previous.roomId,
username: previous.username,
});
}
socket.join(roomId);
socketMeta.set(socket.id, { roomId, username });
io.to(roomId).emit("room:joined", { roomId, username });
// envia as últimas 50 mensagens como histórico inicial
const room = await prisma.room.findUnique({ where: { name: roomId } });
if (room) {
const messages = await prisma.message.findMany({
where: { roomId: room.id },
orderBy: { createdAt: "desc" },
take: 50,
});
socket.emit(
"message:history",
messages.reverse().map(toPayload)
);
}
});
socket.on("message:send", async ({ content }) => {
const meta = socketMeta.get(socket.id);
if (!meta) {
socket.emit("error:custom", { message: "Entre em uma sala antes de enviar mensagens" });
return;
}
if (!content || content.trim().length === 0) return;
if (content.length > 2000) {
socket.emit("error:custom", { message: "Mensagem excede 2000 caracteres" });
return;
}
const room = await prisma.room.findUnique({ where: { name: meta.roomId } });
if (!room) return;
const message = await prisma.message.create({
data: {
content: content.trim(),
username: meta.username,
roomId: room.id,
},
});
io.to(meta.roomId).emit("message:new", toPayload(message));
});
socket.on("typing:start", () => {
const meta = socketMeta.get(socket.id);
if (!meta) return;
// broadcast para todos na sala EXCETO quem está digitando
socket.to(meta.roomId).emit("typing:update", {
username: meta.username,
isTyping: true,
});
});
socket.on("typing:stop", () => {
const meta = socketMeta.get(socket.id);
if (!meta) return;
socket.to(meta.roomId).emit("typing:update", {
username: meta.username,
isTyping: false,
});
});
socket.on("message:loadHistory", async ({ cursor, limit = 50 }) => {
const meta = socketMeta.get(socket.id);
if (!meta) return;
const room = await prisma.room.findUnique({ where: { name: meta.roomId } });
if (!room) return;
const messages = await prisma.message.findMany({
where: {
roomId: room.id,
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {}),
},
orderBy: { createdAt: "desc" },
take: Math.min(limit, 100), // cap em 100 para evitar queries pesadas
});
socket.emit("message:history", messages.reverse().map(toPayload));
});
socket.on("disconnect", () => {
const meta = socketMeta.get(socket.id);
if (meta) {
io.to(meta.roomId).emit("room:left", {
roomId: meta.roomId,
username: meta.username,
});
socketMeta.delete(socket.id);
}
});
}
function toPayload(msg: { id: string; content: string; username: string; roomId: string; createdAt: Date }): MessagePayload {
return {
id: msg.id,
content: msg.content,
username: msg.username,
roomId: msg.roomId,
createdAt: msg.createdAt.toISOString(),
};
}A paginação por cursor (createdAt < cursor) é mais eficiente que offset para mensagens de chat. Com offset, o banco precisa contar N linhas antes de retornar as próximas. Com cursor, ele vai direto ao índice. Isso faz diferença real quando uma sala acumula milhares de mensagens.
Conectando os handlers ao servidor
// src/index.ts
import { io, prisma } from "./server";
import { registerChatHandlers } from "./handlers/chat";
io.on("connection", (socket) => {
registerChatHandlers(io, socket, prisma);
});Separar handlers em módulos não é frescura: quando você adicionar notificações, presença de usuários ou qualquer outro domínio de eventos, cada um vive em seu arquivo com seu register*Handlers. Essa estrutura segue o mesmo princípio de separação por domínio descrito no post sobre Clean Architecture com TypeScript.
Cliente React: hook de Socket.IO com debounce no typing
O cliente usa um hook customizado que encapsula a conexão, os listeners e o debounce do typing indicator. O debounce é o detalhe que separa um chat amador de um funcional: sem ele, cada keystroke dispara um evento typing:start, e o servidor retransmite centenas de eventos por segundo para todos na sala.
// src/hooks/useChat.ts
import { useEffect, useRef, useState, useCallback } from "react";
import { io, Socket } from "socket.io-client";
import type { ServerToClientEvents, ClientToServerEvents, MessagePayload } from "../types/events";
type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
const TYPING_DEBOUNCE_MS = 1500;
export function useChat(serverUrl: string, roomId: string, username: string) {
const [messages, setMessages] = useState<MessagePayload[]>([]);
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<ChatSocket | null>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTypingRef = useRef(false);
useEffect(() => {
const socket: ChatSocket = io(serverUrl, {
// reconnection habilitado por padrão, mas explícito para clareza
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
});
socketRef.current = socket;
socket.on("connect", () => {
setIsConnected(true);
socket.emit("room:join", { roomId, username });
});
socket.on("disconnect", () => setIsConnected(false));
socket.on("message:history", (history) => {
setMessages((prev) => {
// evita duplicatas em reconexão: filtra mensagens já presentes
const existingIds = new Set(prev.map((m) => m.id));
const newMessages = history.filter((m) => !existingIds.has(m.id));
return [...newMessages, ...prev].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
});
});
socket.on("message:new", (message) => {
setMessages((prev) => [...prev, message]);
});
socket.on("typing:update", ({ username: typingUser, isTyping }) => {
setTypingUsers((prev) => {
const next = new Set(prev);
if (isTyping) next.add(typingUser);
else next.delete(typingUser);
return next;
});
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [serverUrl, roomId, username]);
const sendMessage = useCallback((content: string) => {
socketRef.current?.emit("message:send", { content });
}, []);
const handleTyping = useCallback(() => {
if (!isTypingRef.current) {
isTypingRef.current = true;
socketRef.current?.emit("typing:start");
}
// cada keystroke reseta o timer; typing:stop só dispara quando o
// usuário para de digitar por TYPING_DEBOUNCE_MS
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
isTypingRef.current = false;
socketRef.current?.emit("typing:stop");
}, TYPING_DEBOUNCE_MS);
}, []);
return { messages, typingUsers, isConnected, sendMessage, handleTyping };
}O isTypingRef evita emitir typing:start repetidamente enquanto o usuário digita. Só emite uma vez no início, e depois emite typing:stop quando o debounce expira. Isso reduz o tráfego de eventos de typing em mais de 90% comparado com emitir a cada keystroke.
Componente de chat
// src/components/ChatRoom.tsx
import { useState, useRef, useEffect } from "react";
import { useChat } from "../hooks/useChat";
interface ChatRoomProps {
serverUrl: string;
roomId: string;
username: string;
}
export function ChatRoom({ serverUrl, roomId, username }: ChatRoomProps) {
const { messages, typingUsers, isConnected, sendMessage, handleTyping } = useChat(
serverUrl,
roomId,
username
);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage(input);
setInput("");
};
const typingList = Array.from(typingUsers).filter((u) => u !== username);
return (
<div>
<p>{isConnected ? `Conectado à sala ${roomId}` : "Reconectando..."}</p>
<div style={{ height: 400, overflowY: "auto" }}>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.username}</strong>: {msg.content}
<small style={{ marginLeft: 8 }}>
{new Date(msg.createdAt).toLocaleTimeString("pt-BR")}
</small>
</div>
))}
<div ref={messagesEndRef} />
</div>
{typingList.length > 0 && (
<p>
{typingList.join(", ")} {typingList.length === 1 ? "está" : "estão"} digitando...
</p>
)}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => {
setInput(e.target.value);
handleTyping();
}}
placeholder="Digite sua mensagem"
maxLength={2000}
/>
<button type="submit" disabled={!isConnected}>
Enviar
</button>
</form>
</div>
);
}Se o frontend for Next.js com App Router, esse componente precisa do "use client" no topo do arquivo, já que depende de estado e efeitos no navegador. O post sobre Server Components vs Client Components explica quando cada modelo se aplica.
O que NÃO fazer
Erro 1: persistir mensagens depois de emitir
// ERRADO: emite antes de persistir
socket.on("message:send", async ({ content }) => {
io.to(roomId).emit("message:new", { content, username });
// se o banco falha, a mensagem apareceu no chat mas não existe no histórico
await prisma.message.create({ data: { content, username, roomId } });
});O problema: se o create falha (constraint violation, banco fora, timeout), a mensagem já foi entregue para todos os clientes. Quando alguém recarrega a página e busca o histórico, a mensagem sumiu. Isso gera confusão real.
// CORRETO: persiste primeiro, emite depois com o ID do banco
socket.on("message:send", async ({ content }) => {
const message = await prisma.message.create({
data: { content, username, roomId: room.id },
});
// só emite se a persistência funcionou
io.to(roomId).emit("message:new", toPayload(message));
});Erro 2: não limpar listeners no useEffect
// ERRADO: listeners acumulam a cada re-render
useEffect(() => {
const socket = io(serverUrl);
socket.on("message:new", (msg) => setMessages((prev) => [...prev, msg]));
// sem cleanup: se o componente remonta, dois listeners escutam o mesmo evento
}, [serverUrl]);Cada remontagem do componente cria uma nova conexão e novos listeners sem destruir os anteriores. Em um app com navegação entre salas, isso resulta em mensagens duplicadas, triplicadas, e memory leaks progressivos.
// CORRETO: cleanup no return do useEffect
useEffect(() => {
const socket = io(serverUrl);
socket.on("message:new", (msg) => setMessages((prev) => [...prev, msg]));
return () => {
socket.disconnect();
};
}, [serverUrl]);Erro 3: broadcast de typing sem sala
// ERRADO: todos os usuários do servidor veem "digitando"
socket.on("typing:start", () => {
io.emit("typing:update", { username, isTyping: true });
});io.emit envia para todos os sockets conectados ao servidor. socket.to(roomId).emit envia para todos na sala, exceto o emissor. A diferença é entre "funciona no localhost com 2 abas" e "funciona com 50 salas simultâneas".
Socket.IO vs alternativas: quando faz sentido
| Critério | Socket.IO | WebSocket puro (ws) | Server-Sent Events |
|---|---|---|---|
| Fallback automático (polling) | Sim | Não | Não se aplica |
| Reconexão automática | Sim, configurável | Implementação manual | Nativa no browser |
| Salas/namespaces | Nativo | Implementação manual | Não se aplica |
| Bidirecional | Sim | Sim | Unidirecional (server → client) |
| Overhead de protocolo | ~2-5% por mensagem | Nenhum | Mínimo |
| Escalabilidade multi-processo | Via adapter (Redis) | Manual | Simples (stateless) |
Se você precisa de salas, reconexão automática e fallback para ambientes com proxy restritivo, Socket.IO economiza semanas de implementação. Se o requisito é streaming unidirecional (feeds, notificações), SSE é mais simples e leve. Se o overhead do protocolo Socket.IO é inaceitável (jogos com tick rate alto, trading), WebSocket puro com a lib ws dá controle total.
Para escalar Socket.IO além de uma instância, o adapter do Redis é obrigatório. O post sobre comunicação em tempo real em escala detalha como configurar o adapter e distribuir conexões com sticky sessions.
Paginação do histórico com cursor
O message:loadHistory no handler usa paginação por cursor. O cliente envia o createdAt da mensagem mais antiga que ele tem, e o servidor retorna as próximas N anteriores a essa data. Isso é mais eficiente que offset e funciona bem com dados que crescem continuamente.
Se o volume de mensagens por sala cresce acima de 100k registros, considere mover o histórico antigo para uma tabela de arquivo ou usar processamento assíncrono com BullMQ para comprimir e arquivar mensagens antigas em background.
FAQ
Socket.IO funciona com Next.js App Router?
Sim, mas o servidor Socket.IO precisa rodar separado do Next.js. O App Router roda em um ambiente serverless/edge que não mantém conexões longas. Suba o Socket.IO em um processo Node.js independente e conecte o cliente React via socket.io-client. Se precisar autenticação compartilhada, use tokens JWT validados no middleware do Socket.IO e no middleware do Next.js.
Como lidar com reconexão sem perder mensagens?
O hook useChat já trata isso parcialmente: ao reconectar, o cliente re-emite room:join, que dispara o carregamento das últimas 50 mensagens. A deduplicação por id no message:history evita duplicatas. Para garantia mais forte, implemente um lastMessageId no cliente e peça ao servidor apenas mensagens posteriores a esse ID.
Preciso do Redis adapter para dev local?
Não. O adapter do Redis só é necessário quando você roda múltiplas instâncias do servidor (horizontal scaling). Em dev local com um único processo, o Socket.IO mantém todas as salas e sockets em memória. Adicione o adapter quando for para produção com load balancer.
Como limitar o tamanho das salas?
Antes do socket.join(roomId), consulte io.in(roomId).fetchSockets() para contar quantos sockets estão na sala. Se exceder o limite, emita um error:custom e não execute o join. Isso evita salas com milhares de participantes que degradam o broadcast.
O typing indicator consome muita banda?
Com o debounce de 1500ms implementado no hook, cada sessão de digitação gera exatamente 2 eventos: um typing:start e um typing:stop. Sem debounce, uma frase de 50 caracteres digitada em 5 segundos geraria 50 eventos. A diferença é de 2 eventos contra 50 por mensagem composta.
Posição sobre Socket.IO em 2024
Socket.IO carrega um overhead de protocolo que puristas de WebSocket criticam. A crítica é válida para cenários de baixa latência extrema. Para chat, colaboração em documentos, dashboards em tempo real e notificações, esse overhead é irrelevante frente ao que você ganha: reconexão automática, fallback para polling, salas nativas, tipagem de eventos, e um ecossistema de adapters para escalar horizontalmente.
A decisão real não é "Socket.IO vs WebSocket puro". É "quanto tempo quero gastar reimplementando reconexão, heartbeat, salas e serialização". Se a resposta é "zero", Socket.IO continua sendo a escolha pragmática para aplicações Node.js que precisam de comunicação bidirecional confiável.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

WebSockets vs Server-Sent Events: quando usar cada um

WebSockets na Prática: Sistema de Notificações em Tempo Real com Node.js e React

Arquitetando Comunicação em Tempo Real em Escala: Redis Pub/Sub, Load Balancing e Milhares de Conexões Simultâneas
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.