Aplicativo de chat em tempo real com Next.js: como usar WebSocket e SSE corretamente

Era a terceira tentativa de fazer o Next.js funcionar com WebSocket. A página exibia o erro: “WebSocket is not supported in this environment”. O chat tinha acabado de funcionar perfeitamente no ambiente local, mas parou assim que foi implantado na Vercel.
Foi aí que percebi uma coisa: comunicação em tempo real no Next.js está longe de ser tão simples quanto parece.
Este artigo não vai ensinar a encaixar WebSocket à força no Next.js — já percorri esse caminho e ele não funciona. Vou compartilhar os problemas reais que encontrei: por que a Vercel não oferece suporte a WebSocket, se SSE é mesmo a salvação e como fazer Socket.io conviver com o App Router. Se você está quebrando a cabeça com recursos em tempo real no Next.js, espero poupar alguns desvios.
Comparação entre três soluções de comunicação em tempo real
Chats, edição colaborativa e notificações em tempo real dependem de o servidor enviar dados ativamente ao cliente. O modelo de solicitação e resposta do HTTP não resolve isso sozinho, por isso existem três abordagens principais.
WebSocket: o sonho do full duplex
WebSocket parece a solução ideal: depois de estabelecer uma conexão, cliente e servidor podem trocar mensagens a qualquer momento. A reação natural é pensar: por que hesitar?
Eu também pensei assim, até chegar a hora da implantação.
Plataformas Serverless como Vercel e Netlify não aceitam conexões persistentes. O aplicativo Next.js roda em funções de nuvem e, assim que a solicitação termina, a função é descartada. Como ela manteria uma conexão WebSocket? Testei serviços WebSocket de terceiros, como Pusher e Ably, mas a mensalidade me fez desistir.
Isso não significa que WebSocket seja inviável. Você pode alugar um servidor e fazer a implantação por conta própria ou executar o serviço WebSocket em um backend Node.js separado. As duas opções funcionam, mas custam mais e aumentam a complexidade da arquitetura.
SSE: um canal unidirecional que resolve o problema
O nome Server-Sent Events já explica a ideia: é um canal unidirecional, apenas do servidor para o cliente.
Pode parecer limitado: o envio de mensagens ainda passa por HTTP POST, enquanto o recebimento usa SSE. Mas, olhando por outro ângulo, isso se encaixa em muitos cenários. Em um chat, enviar é uma ação do usuário e receber é passivo; notificações em tempo real precisam apenas do envio do servidor.
O mais importante: SSE funciona na Vercel.
Em um sistema simples de notificações que desenvolvi, SSE resolveu o principal problema da implantação na Vercel. Há limitações — as Edge Functions da Vercel têm timeout de 25 segundos —, mas para envio de mensagens foi suficiente.
Long Polling: antigo, mas útil
É a abordagem mais antiga: o cliente faz uma solicitação, o servidor espera até haver uma nova mensagem, responde e o cliente inicia imediatamente a próxima solicitação.
O desempenho não é ótimo e há desperdício de tráfego. Ainda assim, se você tem apenas algumas centenas de usuários e poucas mensagens, Long Polling funciona muito bem: o código é simples, não há problemas de compatibilidade e plataformas Serverless o aceitam.
Já vi muitos projetos pequenos sustentados por Long Polling sem qualquer problema perceptível para o usuário. Não se assuste com o termo “dívida técnica”: uma solução que resolve o problema continua sendo uma boa solução.
Tabela comparativa
| Recurso | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Comunicação bidirecional | ✅ | ❌ (exige POST) | ✅ |
| Compatível com Vercel | ❌ | ✅ (com limite de tempo) | ✅ |
| Compatibilidade com navegadores | Navegadores modernos | Navegadores modernos | Todos |
| Custo da conexão | Baixo | Baixo | Alto |
| Complexidade da implementação | Média | Simples | Muito simples |
| Cenários indicados | Chat, jogos, edição colaborativa | Notificações, atualizações em tempo real | Mensagens pouco frequentes, alta exigência de compatibilidade |
Você provavelmente percebeu: com Next.js + Vercel, SSE e Long Polling são as opções mais comuns. Isso não é um retrocesso técnico, e sim uma escolha pragmática diante das limitações da plataforma.
Como escolher uma solução de tempo real no Next.js
Conhecer as três opções é uma coisa; escolher entre elas é outra. A seguir estão os critérios que aprendi depois de tropeçar em vários problemas.
A plataforma de implantação é a primeira divisão
Se você usa Vercel, Netlify ou Cloudflare Pages, WebSocket praticamente não é uma opção. Restam três caminhos:
- SSE: indicado para envio unidirecional, como notificações, atualizações em tempo real e recebimento de mensagens de chat
- Long Polling: indicado para comunicação bidirecional pouco frequente
- Serviço WebSocket independente: execute o WebSocket em um servidor pequeno e mantenha o aplicativo Next.js em uma plataforma Serverless
Nos meus projetos, escolho SSE sempre que não há comunicação bidirecional realmente frequente, como em edição colaborativa. A implantação é simples e o orçamento agradece.
Se você já usa um servidor próprio, seja VPS ou Docker, pode usar WebSocket sem problema. Nesse caso, porém, balanceamento de carga e gerenciamento de processos também ficam por sua conta.
A quantidade de usuários determina a complexidade da arquitetura
Quantas pessoas ficam online ao mesmo tempo?
- Menos de 100: Long Polling é suficiente, e o código pode ficar pronto em uma hora
- De 100 a 1.000: SSE ou uma solução simples com WebSocket
- Mais de 1.000: você precisará de fila de mensagens com Redis Pub/Sub, balanceamento de carga e implantação com várias instâncias
Conheci uma startup cuja primeira versão usava Long Polling. A equipe só migrou para SSE quando chegou a 500 usuários. É uma boa estratégia: validar a ideia rapidamente no início e otimizar o desempenho depois.
A frequência das mensagens influencia a escolha
Para um sistema de notificações que recebe apenas algumas mensagens por minuto, Long Polling é suficiente. Em um chat com dezenas de pessoas enviando mensagens ao mesmo tempo, SSE ou WebSocket faz mais sentido.
Há outro detalhe fácil de ignorar: o navegador limita o número de conexões HTTP com o mesmo domínio, geralmente a seis. Com Long Polling ou SSE, várias abas abertas podem bloquear conexões. Nesse caso, use WebSocket ou implemente uma detecção para manter apenas uma aba ativa.
O orçamento também é decisivo
Serviços WebSocket de terceiros, como Pusher, Ably e PubNub, são convenientes, mas cobram por volume de mensagens. Fiz as contas uma vez: para um chat com 500 pessoas online, só o WebSocket custaria entre US$ 49 e US$ 99 por mês.
Com infraestrutura própria:
- Vercel + SSE: a franquia gratuita é ampla e pequenos projetos podem não gastar nada
- VPS + WebSocket: a partir de US$ 5 por mês, com Vultr ou DigitalOcean
- Railway/Render: compatível com WebSocket, entre US$ 5 e US$ 10 por mês
Minha árvore de decisão
Implantação na Vercel?
├─ Sim → Mais de 1.000 usuários?
│ ├─ Sim → SSE + Redis Pub/Sub
│ └─ Não → SSE simples ou Long Polling
└─ Não (hospedagem própria) → Precisa de comunicação bidirecional frequente?
├─ Sim → WebSocket + Socket.io
└─ Não → SSE
No fim das contas, não existe escolha técnica absolutamente certa ou errada. Minha experiência é: coloque no ar a solução mais simples e só otimize quando ela realmente não aguentar mais. Muitos projetos começam com um cluster WebSocket e nunca chegam a ter 100 usuários.
Integração prática com Socket.io
Se você decidiu usar WebSocket, por exemplo em um servidor próprio, Socket.io é a biblioteca mais consolidada. Ela faz fallback automático para Long Polling e já inclui reconexão e gerenciamento de salas.
No Next.js, porém, a integração com Socket.io tem mais armadilhas do que parece. O Route Handler do App Router não oferece suporte a res.socket, então é preciso usar um Custom Server.
Etapa 1: criar um Custom Server
O modo de inicialização padrão do Next.js não oferece suporte a WebSocket, por isso precisamos de um servidor personalizado. Crie server.js na raiz do projeto:
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
// 初始化 Socket.io
const io = new Server(httpServer, {
cors: {
origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
methods: ['GET', 'POST']
}
});
// 连接处理
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 加入房间
socket.on('join_room', (roomId) => {
socket.join(roomId);
console.log(`用户 ${socket.id} 加入房间 ${roomId}`);
});
// 接收消息
socket.on('send_message', (data) => {
// 发送给房间内所有人(包括自己)
io.to(data.room).emit('receive_message', {
id: Date.now(),
user: data.user,
message: data.message,
timestamp: new Date().toISOString()
});
});
socket.on('disconnect', () => {
console.log('用户断开:', socket.id);
});
});
httpServer.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://${hostname}:${port}`);
});
});
Altere o package.json:
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
Etapa 2: conectar o cliente
Crie um Hook para encapsular a lógica do cliente Socket.io:
// hooks/useSocket.ts
'use client';
import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io('http://localhost:3000', {
transports: ['websocket', 'polling'] // 优先 WebSocket,降级到 polling
});
socketInstance.on('connect', () => {
console.log('Socket 已连接');
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('Socket 已断开');
setIsConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return { socket, isConnected };
}
Etapa 3: criar o componente de chat
// app/chat/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSocket } from '@/hooks/useSocket';
interface Message {
id: number;
user: string;
message: string;
timestamp: string;
}
export default function ChatPage() {
const { socket, isConnected } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
const roomId = 'general'; // 固定房间,实际项目可以动态生成
useEffect(() => {
if (!socket) return;
// 加入房间
socket.emit('join_room', roomId);
// 监听新消息
socket.on('receive_message', (data: Message) => {
setMessages((prev) => [...prev, data]);
});
return () => {
socket.off('receive_message');
};
}, [socket, roomId]);
const sendMessage = () => {
if (!socket || !inputMessage.trim()) return;
socket.emit('send_message', {
room: roomId,
user: username,
message: inputMessage
});
setInputMessage('');
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? '已连接' : '未连接'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-blue-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
发送
</button>
</div>
</div>
);
}
Problemas que encontrei
-
Hot reload: durante o desenvolvimento, a conexão Socket cai sempre que o código é salvo. É um efeito colateral do hot reload do Next.js e não dá para eliminá-lo completamente.
-
Erro de CORS: se cliente e servidor usam portas diferentes, adicione a opção
corsà configuração do Socket.io. -
Tipos do TypeScript: instale
@types/socket.io-client; caso contrário, o suporte de tipos será bem ruim. -
Implantação: um Custom Server não pode ser implantado na Vercel. Use um VPS ou uma plataforma compatível com WebSocket, como Railway ou Render.
Implementação com SSE (Server-Sent Events)
SSE foi a solução que salvou meus recursos em tempo real na Vercel. O código é bem mais simples que WebSocket e não exige Custom Server.
Servidor: implementar SSE com Route Handler
O Route Handler do App Router pode retornar um ReadableStream, exatamente o que precisamos para SSE:
// app/api/sse/route.ts
import { NextRequest } from 'next/server';
// 模拟消息队列(实际项目用 Redis Pub/Sub)
const messageQueue: { id: string; message: string }[] = [];
const listeners = new Set<(message: any) => void>();
export async function GET(request: NextRequest) {
// 创建可读流
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 发送初始连接消息
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
// 监听新消息
const listener = (message: any) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
};
listeners.add(listener);
// 定期发送心跳,防止连接断开
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
}, 15000); // 每 15 秒一次
// 清理函数
request.signal.addEventListener('abort', () => {
listeners.delete(listener);
clearInterval(heartbeat);
controller.close();
});
}
});
// 返回 SSE 响应
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
// 发送消息的 POST 接口
export async function POST(request: NextRequest) {
const body = await request.json();
const message = {
id: Date.now().toString(),
user: body.user,
message: body.message,
timestamp: new Date().toISOString()
};
// 通知所有监听者
listeners.forEach(listener => listener(message));
return Response.json({ success: true });
}
Cliente: consumir SSE com EventSource
// app/sse-chat/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
interface Message {
id: string;
user: string;
message: string;
timestamp: string;
}
export default function SSEChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
// 建立 SSE 连接
const eventSource = new EventSource('/api/sse');
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 连接已建立');
setIsConnected(true);
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
console.log('收到服务端连接确认');
return;
}
// 收到新消息
setMessages((prev) => [...prev, data]);
};
eventSource.onerror = () => {
console.error('SSE 连接错误');
setIsConnected(false);
};
// 清理函数
return () => {
eventSource.close();
};
}, []);
const sendMessage = async () => {
if (!inputMessage.trim()) return;
try {
await fetch('/api/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: username,
message: inputMessage
})
});
setInputMessage('');
} catch (error) {
console.error('发送消息失败:', error);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? 'SSE 已连接' : 'SSE 未连接'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-purple-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-purple-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
发送
</button>
</div>
</div>
);
}
Como SSE se comportou na prática
Sendo sincero, SSE não é perfeito. Encontrei alguns problemas durante o uso:
-
A Vercel tem timeout de 25 segundos: depois desse tempo, a Edge Function é encerrada à força. Minha solução foi fazer o cliente detectar a queda e reconectar automaticamente.
-
Limite de conexões do navegador: em HTTP/1.1, há no máximo seis conexões com o mesmo domínio. Abrir várias abas pode bloquear conexões. A solução é usar HTTP/2, compatível por padrão com a Vercel, ou detectar várias abas.
-
Broadcast de mensagens: o código acima funciona com uma única instância, mas a Vercel usa várias instâncias. Para transmitir mensagens de verdade, é preciso usar Redis Pub/Sub ou Upstash.
Usar Redis para transmitir mensagens entre instâncias
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// app/api/sse/route.ts(改进版)
import { redis } from '@/lib/redis';
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Redis 订阅
const channelName = 'chat_messages';
// 轮询 Redis(Upstash 不支持原生 SUBSCRIBE)
const interval = setInterval(async () => {
const messages = await redis.lrange(channelName, 0, -1);
// 处理消息...
}, 1000);
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
}
Quando usar SSE?
Minha recomendação:
- ✅ Sistemas de notificações em tempo real
- ✅ Atualização de preços de ações
- ✅ Visualização de logs em tempo real
- ✅ Atualização de barras de progresso
- ❌ Chat bidirecional de alta frequência — use WebSocket
- ❌ Jogos online — use WebSocket
Estado das mensagens e sincronização de dados
Comunicação em tempo real não se resume a enviar e receber mensagens. O usuário vai perguntar: minha mensagem foi enviada? A outra pessoa recebeu? Vou perder a mensagem se a conexão cair?
Por trás dessas perguntas estão o gerenciamento de estado das mensagens e a sincronização de dados. Passei vários dias travado nessa parte ao desenvolver um chat.
Quatro estados de uma mensagem
Seguindo o design do WeChat, uma mensagem deve ter pelo menos quatro estados:
- Enviando: o usuário clicou em enviar, mas o servidor ainda não respondeu
- Enviada: o servidor recebeu, mas o destinatário talvez ainda não
- Entregue: o cliente do destinatário recebeu
- Falha no envio: houve erro de rede ou rejeição do servidor
Podemos gerenciar isso com uma máquina de estados simples:
// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';
export interface Message {
id: string;
localId: string; // 客户端生成的临时 ID
user: string;
content: string;
timestamp: string;
status: MessageStatus;
}
Atualização otimista + novas tentativas
Não espere a resposta do servidor para exibir a mensagem; isso piora muito a experiência. Com uma atualização otimista, mostramos o status “Enviando” antes da solicitação e o alteramos quando o servidor responder.
// hooks/useChat.ts
'use client';
import { useState } from 'react';
import { Message, MessageStatus } from '@/types/message';
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const sendMessage = async (content: string, username: string) => {
// 生成临时 ID
const localId = `local_${Date.now()}_${Math.random()}`;
// 乐观更新:立刻显示消息
const tempMessage: Message = {
id: '',
localId,
user: username,
content,
timestamp: new Date().toISOString(),
status: 'sending'
};
setMessages((prev) => [...prev, tempMessage]);
try {
// 发送到服务器
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: username, message: content })
});
const data = await response.json();
// 更新为"已发送"
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId
? { ...msg, id: data.id, status: 'sent' }
: msg
)
);
} catch (error) {
// 发送失败
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'failed' } : msg
)
);
}
};
const retryMessage = async (localId: string) => {
const message = messages.find((msg) => msg.localId === localId);
if (!message) return;
// 重置状态为"发送中"
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'sending' } : msg
)
);
// 重新发送
await sendMessage(message.content, message.user);
};
return { messages, sendMessage, retryMessage };
}
Reconexão e persistência das mensagens
Quando a rede está instável, a conexão cai. Como evitar a perda de mensagens depois da reconexão?
Minha solução é armazenar no IndexedDB as mensagens ainda não confirmadas e reenviá-las após reconectar.
// lib/indexedDB.ts
const DB_NAME = 'ChatDB';
const STORE_NAME = 'pendingMessages';
export async function openDB() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'localId' });
}
};
});
}
export async function savePendingMessage(message: Message) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).add(message);
}
export async function removePendingMessage(localId: string) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(localId);
}
export async function getAllPendingMessages(): Promise<Message[]> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
});
}
O que aprendi na prática
- Não busque perfeição logo no início: implemente primeiro o envio e o recebimento básicos, depois adicione o gerenciamento de estado.
- Priorize falhas de envio: o usuário se importa mais em saber se a mensagem saiu do que com o recurso de confirmação de leitura.
- IndexedDB é uma salvação: em redes ruins, o armazenamento local faz toda a diferença.
- Teste cenários offline: use a opção Offline na aba Network do Chrome DevTools e repita os testes.
Implantação em produção e otimização de desempenho
Tudo funciona bem no ambiente de desenvolvimento e começa a dar problema em produção: essa é a experiência mais comum com comunicação em tempo real. Reuni os pontos principais para ajudar você a evitar algumas armadilhas.
Escolha da plataforma e suas limitações
Já vimos que a Vercel não oferece suporte a WebSocket. Esta tabela detalha as plataformas:
| Plataforma | WebSocket | SSE | Limitações específicas |
|---|---|---|---|
| Vercel | ❌ | ✅ | Edge: timeout de 25 s; Serverless: timeout de 60 s |
| Netlify | ❌ | ✅ | Timeout de 10 s para Functions |
| Railway | ✅ | ✅ | Sem timeout rígido; cobrança por tráfego |
| Render | ✅ | ✅ | O plano gratuito entra em suspensão |
| Cloudflare Pages | ❌ | ✅ | Workers têm limite de tempo de CPU |
| VPS próprio | ✅ | ✅ | Você gerencia o servidor |
Minha recomendação:
- Orçamento apertado + baixa concorrência: Vercel + SSE, com uma boa franquia gratuita
- Precisa de WebSocket: Railway ou Render, por US$ 5 a US$ 10 por mês
- Alta concorrência + orçamento disponível: VPS próprio + proxy reverso com Nginx
Otimizar SSE na Vercel
As Edge Functions da Vercel têm apenas 25 segundos de execução. O que fazer? Minha solução é reconexão automática + heartbeat:
// hooks/useSSE.ts
'use client';
import { useEffect, useRef, useState } from 'react';
export function useSSE(url: string) {
const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = () => {
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 已连接');
setIsConnected(true);
};
eventSource.onerror = () => {
console.error('SSE 连接错误,3 秒后重连...');
setIsConnected(false);
eventSource.close();
// 自动重连
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
return eventSource;
};
useEffect(() => {
const eventSource = connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
eventSource.close();
};
}, [url]);
return { eventSource: eventSourceRef.current, isConnected };
}
Sincronizar mensagens em várias instâncias
A Vercel cria várias instâncias automaticamente. Se o usuário A está conectado à instância 1 e o usuário B à instância 2, como eles trocam mensagens?
A resposta é Redis Pub/Sub.
Já implementei uma solução com Upstash, um Redis serverless:
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// 发送消息到 Redis
export async function publishMessage(channel: string, message: any) {
await redis.lpush(channel, JSON.stringify(message));
await redis.ltrim(channel, 0, 99); // 只保留最近 100 条
}
// 获取消息历史
export async function getRecentMessages(channel: string) {
const messages = await redis.lrange(channel, 0, -1);
return messages.map((m) => JSON.parse(m as string)).reverse();
}
Checklist de otimização de desempenho
-
Desduplicar mensagens: o cliente pode receber a mesma mensagem mais de uma vez; registre os IDs recebidos em um
Set. -
Rolagem virtual: acima de 100 mensagens, renderize com
react-windowoureact-virtualized. -
Carregamento preguiçoso do histórico: não carregue todo o histórico de uma vez; busque mais ao chegar ao topo.
-
Limitação de frequência: impeça o envio excessivo de mensagens, tanto no cliente quanto no servidor.
// 简单的客户端限流
let lastSendTime = 0;
const SEND_INTERVAL = 500; // 500ms 内只能发一条
const sendMessage = async (content: string) => {
const now = Date.now();
if (now - lastSendTime < SEND_INTERVAL) {
alert('发送太快了,请稍后再试');
return;
}
lastSendTime = now;
// ... 发送逻辑
};
- Monitoramento e logs: use Sentry ou LogRocket para rastrear erros de conexão SSE e falhas no envio de mensagens.
Controle de custos
Recursos em tempo real podem sair caros, especialmente serviços WebSocket. Algumas formas de economizar:
- Substitua WebSocket por SSE quando possível: isso elimina o custo de um servidor WebSocket separado
- Agrupe mensagens: em vez de enviar cada mensagem individualmente, reúna as mensagens e envie um lote por segundo
- Use Upstash para Redis: a cobrança por solicitação costuma ser mais barata que manter um servidor Redis próprio
- Use CDN para recursos estáticos: distribua os arquivos estáticos do aplicativo Next.js por CDN para reduzir a carga do servidor
Desenvolvi um chat com 500 pessoas online usando Vercel + Upstash por menos de US$ 15 ao mês. O segredo é escolher a arquitetura certa.
Conclusão
Voltando à falha daquela madrugada, no início do artigo: se eu soubesse tudo isso, não teria insistido tanto em WebSocket.
Na comunicação em tempo real com Next.js, o ponto central é fazer escolhas pragmáticas:
- Vai implantar na Vercel? Use SSE em vez de forçar WebSocket
- O orçamento é limitado? Comece com a opção mais simples, sem acumular tecnologia desnecessária
- A experiência do usuário é prioridade? Gerenciamento de estado e reconexão importam mais que uma arquitetura chamativa
Você pode usar diretamente os exemplos deste artigo, mas o mais importante é entender os compromissos por trás das escolhas. Não existe solução universal; a melhor é a que combina com o seu projeto.
Se você também está criando recursos em tempo real, espero que este artigo poupe alguns desvios. Se tiver dúvidas, deixe um comentário e tentarei responder.
Próximos passos:
- Execute localmente o exemplo de SSE mais simples possível
- Implante na Vercel e faça um teste
- Se realmente precisar de WebSocket, considere Railway ou hospedagem própria
Dívida técnica não é o problema; o problema é assumir uma dívida desnecessária desde o começo. Boa sorte!
FAQ
Por que a Vercel não oferece suporte a WebSocket?
Há três alternativas:
• Usar SSE (Server-Sent Events), com suporte nativo da Vercel
• Alugar um servidor dedicado (VPS) ou usar uma plataforma compatível com WebSocket, como Railway ou Render
• Usar um serviço WebSocket de terceiros, como Pusher ou Ably, embora o custo seja maior, entre US$ 49 e US$ 99 por mês
Na maioria dos cenários, SSE já é suficiente e custa menos.
SSE ou WebSocket: qual é melhor para um aplicativo de chat?
• SSE é indicado quando predomina o envio unidirecional, como notificações em tempo real e recebimento de mensagens em chats, em plataformas Serverless como Vercel e Netlify e para até 1.000 usuários
• WebSocket é indicado para comunicação bidirecional de alta frequência, como edição colaborativa e jogos online, quando há servidor próprio e necessidade de baixa latência
Em geral, um chat pode combinar SSE para receber mensagens e HTTP POST para enviá-las. O custo é menor e a implantação, mais simples. Já desenvolvi um chat com 500 pessoas online usando SSE + Upstash Redis por menos de US$ 15 ao mês.
Como lidar com o limite de 25 segundos da Vercel?
• O servidor envia um heartbeat a cada 15 segundos para evitar que a conexão seja considerada ociosa
• O cliente detecta a interrupção pelo evento onerror do EventSource
• Ao detectar a queda, reconecta automaticamente após 3 segundos
• As mensagens ficam no Redis, garantindo que o cliente recupere as não lidas depois da reconexão
Na prática, o usuário quase não percebe a reconexão, e a experiência se aproxima de uma conexão persistente.
Como sincronizar mensagens em uma implantação com várias instâncias?
• O usuário A envia uma mensagem → a instância 1 grava no Redis
• As instâncias 1, 2 e 3 consultam o Redis em busca de novas mensagens
• Cada instância envia a mensagem aos usuários conectados a ela
Recomendo o Upstash, um Redis serverless cobrado por solicitação e mais barato que manter um Redis próprio. Um intervalo de consulta de 1 segundo equilibra tempo real e custo.
Como evitar a perda de mensagens?
• Atualização otimista: a mensagem aparece imediatamente na interface com o status 'Enviando'
• Armazenamento local no IndexedDB: mensagens não confirmadas ficam salvas no banco de dados local
• Confirmação do servidor: após uma resposta bem-sucedida, o status muda para 'Enviada'
• Recuperação após reconexão: o cliente lê as mensagens não confirmadas do IndexedDB e as reenvia automaticamente
• Mecanismo de nova tentativa: mensagens com falha exibem um botão para o usuário tentar novamente
A referência é o design de estados do WeChat: enviando, enviada, entregue e falha no envio.
É possível implantar um Custom Server do Socket.io na Vercel?
Alternativas:
• Usar uma plataforma compatível com WebSocket: Railway a partir de US$ 5 por mês ou Render a partir de US$ 7 por mês
• Hospedar em um VPS, como Vultr ou DigitalOcean, a partir de US$ 5 por mês
• Fazer uma implantação híbrida: o aplicativo Next.js fica na Vercel e o serviço WebSocket é implantado separadamente
Se for obrigatório usar a Vercel, prefira SSE, que atende à maioria dos requisitos de comunicação em tempo real.
Quais são os gargalos de desempenho de um chat em tempo real?
• Renderização da lista: acima de 100 mensagens, use rolagem virtual com react-window
• Carregamento do histórico: faça carregamento preguiçoso e paginação ao chegar ao topo
• Limitação no cliente: permita apenas uma mensagem a cada 500 ms para dificultar spam
• Limitação no servidor: adicione Rate Limiting à rota de API
• Desduplicação: registre IDs já recebidos em um Set para evitar renderizações repetidas
• Limite de conexões: HTTP/1.1 permite no máximo 6 conexões por domínio; use HTTP/2 ou detecte várias abas
Ferramentas recomendadas: Sentry para rastreamento de erros, LogRocket para reprodução de sessões e Vercel Analytics para monitoramento de desempenho.
20 min de leitura · Publicado em: 7 jan 2026 · Atualizado em: 4 set 2026
Guia completo Next.js
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Segurança e autenticação de APIs no Next.js: guia prático completo de JWT a rate limiting
Guia prático completo de segurança para APIs no Next.js: aprenda a criar aplicações confiáveis e prontas para produção com autenticação JWT, configuração de CORS, rate limiting, validação de entrada e prevenção contra vulnerabilidades recentes.
Parte 12 de 26
Próximo
Guia para escolher banco de dados no Next.js: comparação completa entre PostgreSQL, MySQL, MongoDB e serviços em nuvem
Não sabe qual banco de dados escolher para seu projeto Next.js? Este guia compara PostgreSQL, MySQL e MongoDB, analisa Vercel Postgres, Supabase, PlanetScale e MongoDB Atlas e apresenta 3 perguntas para você decidir com rapidez e evitar armadilhas.
Parte 14 de 26



Comentários
Entre com GitHub para comentar