Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
15 minutos de leitura
Ilustracao 3D de camaras de vidro translucido conectadas por condutos luminosos representando chat com Socket.IO
Ouça este artigo
0:00Socket.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.

TypeScript
// 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
// 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.

TypeScript
// 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.

TypeScript
// 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

TypeScript
// 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.

TSX
// 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

TSX
// 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

TypeScript
// 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.

TypeScript
// 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

TSX
// 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.

TSX
// 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

TypeScript
// 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érioSocket.IOWebSocket puro (ws)Server-Sent Events
Fallback automático (polling)SimNãoNão se aplica
Reconexão automáticaSim, configurávelImplementação manualNativa no browser
Salas/namespacesNativoImplementação manualNão se aplica
BidirecionalSimSimUnidirecional (server → client)
Overhead de protocolo~2-5% por mensagemNenhumMínimo
Escalabilidade multi-processoVia adapter (Redis)ManualSimples (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.

Marcos Soares

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

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.