Alternar tema

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

Easton editorial illustration: performance inspection lens

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

Baixo
Custo da conexão WebSocket
Um handshake, conexão persistente
Simples
Complexidade da implementação com SSE
Suporte nativo do navegador com EventSource
Todos
Compatibilidade do Long Polling
Compatível com todos os navegadores
Priorize SSE
Facilidade de implantação na Vercel
WebSocket não é compatível
RecursoWebSocketSSELong Polling
Comunicação bidirecional❌ (exige POST)
Compatível com Vercel✅ (com limite de tempo)
Compatibilidade com navegadoresNavegadores modernosNavegadores modernosTodos
Custo da conexãoBaixoBaixoAlto
Complexidade da implementaçãoMédiaSimplesMuito simples
Cenários indicadosChat, jogos, edição colaborativaNotificações, atualizações em tempo realMensagens 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:

  1. SSE: indicado para envio unidirecional, como notificações, atualizações em tempo real e recebimento de mensagens de chat
  2. Long Polling: indicado para comunicação bidirecional pouco frequente
  3. 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

  1. 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.

  2. Erro de CORS: se cliente e servidor usam portas diferentes, adicione a opção cors à configuração do Socket.io.

  3. Tipos do TypeScript: instale @types/socket.io-client; caso contrário, o suporte de tipos será bem ruim.

  4. 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:

  1. 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.

  2. 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.

  3. 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:

  1. Enviando: o usuário clicou em enviar, mas o servidor ainda não respondeu
  2. Enviada: o servidor recebeu, mas o destinatário talvez ainda não
  3. Entregue: o cliente do destinatário recebeu
  4. 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

  1. Não busque perfeição logo no início: implemente primeiro o envio e o recebimento básicos, depois adicione o gerenciamento de estado.
  2. 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.
  3. IndexedDB é uma salvação: em redes ruins, o armazenamento local faz toda a diferença.
  4. 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:

PlataformaWebSocketSSELimitações específicas
VercelEdge: timeout de 25 s; Serverless: timeout de 60 s
NetlifyTimeout de 10 s para Functions
RailwaySem timeout rígido; cobrança por tráfego
RenderO plano gratuito entra em suspensão
Cloudflare PagesWorkers têm limite de tempo de CPU
VPS próprioVocê 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

  1. Desduplicar mensagens: o cliente pode receber a mesma mensagem mais de uma vez; registre os IDs recebidos em um Set.

  2. Rolagem virtual: acima de 100 mensagens, renderize com react-window ou react-virtualized.

  3. Carregamento preguiçoso do histórico: não carregue todo o histórico de uma vez; busque mais ao chegar ao topo.

  4. 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;
  // ... 发送逻辑
};
  1. 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:

  1. Execute localmente o exemplo de SSE mais simples possível
  2. Implante na Vercel e faça um teste
  3. 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?
A Vercel usa uma arquitetura Serverless, na qual a aplicação é executada em funções de nuvem. Quando uma solicitação termina, seus recursos são liberados imediatamente, o que impede manter a conexão persistente exigida pelo 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?
Depende da plataforma de implantação e dos requisitos:

• 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?
As Edge Functions da Vercel têm limite de 25 segundos. A solução é reconectar automaticamente no cliente:

• 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?
A Vercel cria várias instâncias automaticamente, e usuários diferentes podem se conectar a instâncias diferentes. A solução é usar Redis Pub/Sub:

• 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?
Problemas de rede podem causar perda de mensagens. Uma solução completa inclui:

• 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?
Não. Um Custom Server exige um processo Node.js executado continuamente, enquanto a Vercel oferece apenas funções Serverless.

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?
Gargalos comuns e formas de otimização:

• 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

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog