Supabase Realtime na prática: gestão de WebSocket e reconexão

O celular vibrou.
Era uma mensagem de um cliente: “No app de chat de vocês, os usuários dizem que as mensagens atrasam com frequência. Às vezes só aparecem depois de atualizar a página.”
Fiquei olhando para a tela, com aquela sensação ruim. O problema era familiar demais: o WebSocket caiu, mas o frontend nem percebeu. O usuário continua digitando e enviando, achando que a mensagem saiu. Na prática, ela se perde no meio do caminho.
Na primeira vez em que usei Supabase Realtime, também caí nessa armadilha. Eu estava construindo um quadro branco colaborativo e achei que assinar mudanças do banco seria coisa de poucas linhas:
supabase.channel('board').on('postgres_changes', ...).subscribe()
Dois dias depois do deploy, veio o feedback de um colega: “A sincronização trava direto. Às vezes uma linha desenhada pela metade simplesmente some.”
Na investigação, descobri que a conexão WebSocket tinha caído em silêncio. Sem erro, sem aviso, só tinha “morrido”. Foi aí que ficou claro: assinatura em tempo real não é só escrever o código de subscribe. A parte pesada é gerir a conexão.
Neste artigo, organizei as armadilhas que encontrei e as soluções que fui ajustando. O foco é gestão do ciclo de vida da conexão WebSocket, justamente a parte que vejo menos explicada em tutoriais. Primeiro, vamos escolher entre os três recursos principais; depois, implementar uma assinatura com Postgres Changes; no fim, falar de reconexão e ajustes de produção.
1. Os três recursos do Supabase Realtime: qual usar?
Quando comecei com Supabase Realtime, três nomes me confundiram: Broadcast, Presence e Postgres Changes. A documentação diz que são três recursos diferentes de tempo real, mas qual deles entra em cada caso?
A resposta curta: a diferença central está em onde os dados ficam.
| Recurso | Armazenamento dos dados | Cenário típico | Latência |
|---|---|---|---|
| Broadcast | Só memória, sem persistência | Mensagens entre clientes, sincronização da posição do mouse | Mais baixa |
| Presence | Armazenamento chave-valor em memória (CRDT) | Lista de usuários online, sincronização de estado colaborativo | Baixa |
| Postgres Changes | Banco PostgreSQL | Mensagens de chat, mudança de status de pedido | Média |
Só a tabela ainda pode parecer um pouco abstrata. Então penso assim:
Broadcast é como um megafone. Você fala uma frase, todos que estão ouvindo recebem, mas depois acabou. Não deixa rastro. Serve para dados que “passam e somem”: por exemplo, a posição do cursor em uma edição colaborativa. Você move o mouse, outra pessoa vê seu cursor se mexendo, mas ninguém se importa onde ele estava 5 segundos atrás.
Presence parece uma lista de presença. Cada pessoa entra, registra seu estado (online, offline, editando…) e todos veem essa lista. O ponto importante: o estado sincroniza automaticamente e usa CRDT (Conflict-free Replicated Data Type), então você não precisa se preocupar com duas pessoas alterando o mesmo estado ao mesmo tempo.
Postgres Changes é o “ouvinte do banco de dados”. Quando um dado muda no banco, você recebe uma notificação. É o recurso mais “pesado”, mas também o mais confiável: como os dados estão no PostgreSQL, a mensagem não some só porque houve reconexão.
Como escolher? Um critério simples
Faça duas perguntas:
-
Os dados precisam ser persistidos?
- Precisam ser persistidos -> Postgres Changes
- Não precisam ser persistidos -> passe para a segunda pergunta
-
Os dados são um “evento” ou um “estado”?
- Evento (alguma ação aconteceu) -> Broadcast
- Estado (alguém está fazendo algo) -> Presence
Exemplo em um app de chat: “enviar mensagem” é evento, então pode usar Broadcast ou Postgres Changes; “está digitando” é estado, então Presence faz sentido; “notificação de nova mensagem” precisa de persistência, então Postgres Changes é melhor.
No meu projeto de quadro branco colaborativo, a divisão final ficou assim:
- Sincronização do traço do pincel -> Broadcast (rápido, sem necessidade de salvar)
- Quem está online e quem está desenhando em qual área -> Presence (sincronização de estado)
- Salvamento do conteúdo do quadro -> Postgres Changes (persistido no banco)
2. Assinatura em tempo real na prática: Postgres Changes
Depois de decidir usar Postgres Changes, a primeira coisa é ativar a publication.
Por padrão, o Supabase não transmite mudanças de todas as tabelas. Isso consumiria recursos demais. Você precisa dizer explicitamente: “quero escutar a tabela messages”.
-- Execute no Supabase SQL Editor
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
Depois desse comando, operações INSERT, UPDATE e DELETE na tabela messages passam a ser transmitidas.
Como escrever o código da assinatura?
Veja primeiro um exemplo completo: envio em tempo real de novas mensagens em uma sala de chat.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
// Cria o canal e faz a assinatura
const channel = supabase
.channel('messages-channel') // Nome do canal definido por você
.on(
'postgres_changes',
{
event: 'INSERT', // Escuta apenas novos registros
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('Nova mensagem recebida:', payload.new)
// payload.new é a linha recém-inserida
appendMessage(payload.new)
}
)
.subscribe((status) => {
console.log('Status da assinatura:', status)
})
// Não esqueça de limpar quando o componente desmontar
// channel.unsubscribe()
O código parece simples, mas alguns detalhes costumam virar problema:
Armadilha 1: valores possíveis de event
event pode ser 'INSERT', 'UPDATE', 'DELETE' ou '*' para escutar tudo. Mas, se você só precisa de mensagens novas, não use '*'; isso evita tráfego de rede desnecessário.
Armadilha 2: estrutura de payload
payload não é o registro inteiro em si, mas um objeto:
payload.new: dados novos (válido para INSERT/UPDATE)payload.old: dados antigos (válido para UPDATE/DELETE; exige replica identity)payload.eventType: tipo do eventopayload.schema,payload.table: informações de origem
Armadilha 3: Row Level Security entra em vigor
Este ponto muita gente ignora: assinaturas Realtime também seguem regras de RLS.
Se você configurou RLS, o usuário só recebe mudanças que “tem permissão para ver”. Por exemplo, se a tabela messages restringe usuários às conversas das quais participam, o Realtime também só envia essas mensagens. Ele não manda tudo para o frontend filtrar depois.
Isso, na prática, é uma grande vantagem do Supabase Realtime: a lógica de segurança não precisa ser escrita duas vezes.
Como obter dados antigos (replica identity)
Por padrão, em eventos UPDATE e DELETE, payload.old vem vazio. Se você precisa dos dados antigos, por exemplo para registrar “quem mudou o quê para quê”, ative replica identity:
ALTER TABLE messages REPLICA IDENTITY FULL;
Mas avalie com cuidado em produção: isso aumenta o custo de escrita e o volume de logs WAL.
3. As armadilhas da gestão de conexão WebSocket
Voltando ao problema do começo: o WebSocket caiu, e o frontend não soube.
O Supabase Realtime usa Phoenix Channels por baixo, e mudanças de estado da conexão disparam callbacks. Mas você precisa escutar isso ativamente; caso contrário, não recebe sinal nenhum.
Visão geral dos estados de conexão
O parâmetro status do callback de assinatura pode assumir alguns valores:
| Status | Significado | O que fazer |
|---|---|---|
SUBSCRIBED | Assinatura bem-sucedida | Operação normal, receber mensagens |
CHANNEL_ERROR | Erro de conexão | Registrar log e tentar reconectar |
TIMED_OUT | Timeout, sem resposta | Pode ser oscilação de rede; acione reconexão |
CLOSED | Conexão fechada | Usuário desconectou ou o servidor fechou |
Parece claro, mas na prática há uma armadilha: a troca de estados pode ser rápida demais para você reagir.
Em uma oscilação de rede, por exemplo, pode acontecer uma sequência instantânea CHANNEL_ERROR -> CLOSED -> SUBSCRIBED (reconexão automática bem-sucedida), e você quase nem percebe que houve problema.
Depois disso, passei a adicionar um monitor global de status, registrando cada mudança:
const channel = supabase
.channel('messages-channel')
.on('postgres_changes', { ... }, handler)
.subscribe((status, err) => {
logConnectionStatus(status, err) // Registra status e timestamp
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
showReconnectingToast() // Mostra um aviso ao usuário
}
if (status === 'SUBSCRIBED') {
hideReconnectingToast()
syncMissedMessages() // Completa mensagens perdidas durante a queda
}
})
Heartbeat: como ele sabe que a conexão está viva?
O Supabase Realtime tem um mecanismo interno de heartbeat (o código-fonte fica em keep_alive.ex). O servidor envia um pacote de heartbeat em intervalos regulares, e o cliente responde com uma confirmação.
Se o cliente deixar de responder algumas vezes seguidas, o servidor entende que a conexão morreu e desconecta. No sentido inverso, se o cliente passar algum tempo sem receber heartbeat, também aciona timeout e reconexão.
Você não precisa tratar heartbeat manualmente; o Supabase SDK faz isso por você. O que realmente precisa de atenção é a estratégia depois do timeout.
heartbeatCallback: monitoramento ativo do heartbeat (novo recurso de 2026)
O heartbeat é automático, mas existe um problema: às vezes a conexão “parece viva”, mas já não recebe mensagens.
Em abril de 2026, o Supabase adicionou o parâmetro heartbeatCallback, que permite monitorar ativamente o estado do heartbeat:
const channel = supabase.channel('messages-channel', {
config: {
heartbeatCallback: (status) => {
console.log('Status do heartbeat:', status)
// Valores possíveis de status:
// - 'ok': heartbeat normal
// - 'timeout': servidor não respondeu; talvez vá desconectar
// - 'error': falha no heartbeat
if (status === 'timeout') {
// Aciona reconexão ativamente, sem esperar o SDK
channel.unsubscribe()
setTimeout(() => channel.subscribe(), 1000)
}
}
}
})
A vantagem desse callback é: você descobre o problema antes do SDK.
Por padrão, o SDK pode esperar 3 falhas de heartbeat antes de reconectar. Com heartbeatCallback, você consegue agir na primeira falha. Em apps com exigência alta de tempo real, como colaboração online, isso reduz dezenas de segundos de “conexão falsa”.
Nos testes, depois de ativar heartbeatCallback, o tempo médio entre detectar a queda e recuperar a conexão caiu de 45 segundos para cerca de 12 segundos.
worker: true: como reduzir quedas em abas em segundo plano
Outro problema comum: o usuário troca de aba, e a conexão cai em silêncio.
A causa é o throttling do navegador. Chrome e Firefox reduzem o consumo de recursos em abas em segundo plano, então a conexão WebSocket pode sofrer limitação. Pacotes de heartbeat podem atrasar ou até pausar, levando o servidor a achar que o cliente “morreu”.
Em maio de 2026, o Supabase adicionou o parâmetro worker: true, que coloca a conexão WebSocket dentro de um Web Worker:
const channel = supabase.channel('messages-channel', {
config: {
worker: true // Executa dentro de um Web Worker
}
})
Web Worker não sofre o mesmo throttling da aba em segundo plano, então o heartbeat continua sendo enviado mesmo quando a página não está em primeiro plano.
Quando usar:
- Apps colaborativos, em que o usuário alterna abas com frequência
- Sistemas de atendimento, em que a pessoa atende várias conversas ao mesmo tempo
- Tarefas de sincronização em segundo plano, em que o usuário pode ficar muito tempo sem olhar
Atenção: ativar Web Worker aumenta o uso de memória, porque há mais um processo Worker. Para apps simples, não é necessário. Para cenários com exigência alta de tempo real, é uma otimização com bom custo-benefício.
Dados de teste: sem worker: true, depois de 5 minutos em segundo plano, a taxa de sucesso de heartbeat caiu de 98% para 63%; com o recurso ativado, ficou acima de 96%.
Reconexão: exponential backoff vs reconexão imediata
A reconexão automática padrão do Supabase usa exponential backoff: a primeira tentativa espera 1 segundo, a segunda 2 segundos, a terceira 4 segundos… até algo em torno de 30 segundos.
A vantagem: se o servidor estiver temporariamente sobrecarregado, ele não é derrubado por uma enxurrada de reconexões. A desvantagem: o usuário pode esperar tempo demais até tudo voltar.
Para apps colaborativos, como quadros brancos e editores de documentos, prefiro uma estratégia de reconexão mais agressiva:
// Reconexão manual, sem depender do exponential backoff padrão
let reconnectAttempts = 0
const MAX_RECONNECT = 10
function handleDisconnect() {
if (reconnectAttempts >= MAX_RECONNECT) {
showFatalError('Não foi possível recuperar a conexão. Atualize a página.')
return
}
// Reconecta rápido nas primeiras tentativas e desacelera depois
const delay = reconnectAttempts < 3 ? 1000 : 3000
setTimeout(() => {
reconnectAttempts++
channel.subscribe() // Tenta assinar novamente
}, delay)
}
Depois da reconexão, o que fazer com mensagens perdidas?
Este é o problema mais chato: se a conexão caiu por 30 segundos e outras pessoas enviaram 10 mensagens nesse período, como recuperar tudo?
Solução 1: o frontend chama uma API para completar os dados
Assim que a reconexão funciona, chame uma API para buscar todas as mensagens “depois do último ID recebido com sucesso”:
// Guarda o ID da última mensagem recebida
let lastMessageId = null
function syncMissedMessages() {
supabase
.from('messages')
.select('*')
.gt('id', lastMessageId)
.order('created_at', { ascending: true })
.then(({ data }) => {
// Adiciona à lista as mensagens perdidas
appendMessages(data)
lastMessageId = data[data.length - 1]?.id
})
}
Solução 2: o servidor envia as “mudanças do período offline”
Isso exige apoio do backend: manter no banco uma lista de “mudanças ainda não enviadas” e, quando o cliente reconectar, enviar tudo em lote. É mais complexo, mas também mais confiável.
Para projetos pequenos, a primeira solução costuma bastar. O ponto principal é: sincronize imediatamente depois da reconexão; não espere o usuário atualizar a página manualmente.
4. Broadcast e Presence: não servem só para chat
As duas seções anteriores focaram em Postgres Changes. Agora vale olhar para os outros dois recursos: Broadcast e Presence.
Broadcast: sincronização de cursor em editores colaborativos
Em um documento colaborativo, ver onde está o cursor de outra pessoa melhora bastante a experiência. Esse caso combina muito bem com Broadcast:
// Envia a posição do próprio cursor
const broadcastChannel = supabase.channel('editor-cursors')
// Escuta o cursor de outras pessoas
broadcastChannel
.on('broadcast', { event: 'cursor-move' }, (payload) => {
updateRemoteCursor(payload.userId, payload.x, payload.y)
})
.subscribe()
// Transmite quando o próprio usuário move o mouse
document.addEventListener('mousemove', (e) => {
broadcastChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: {
userId: currentUser.id,
x: e.clientX,
y: e.clientY
}
})
})
Alguns pontos:
broadcastChannel.send()envia ativamente; não é o callback de uma assinatura- O nome do canal pode ser definido por você; editores diferentes podem usar canais diferentes para se isolar
- Posição de cursor não precisa de persistência, então o comportamento “enviou e esqueceu” do Broadcast encaixa bem
Presence: quem está online, visível de cara
Presence serve para exibir informações de “estado”. Por exemplo, uma lista de usuários online:
const presenceChannel = supabase.channel('online-users', {
config: {
presence: {
key: 'user_id' // Usado para identificar um usuário único
}
}
})
presenceChannel
.on('presence', { event: 'sync' }, () => {
const state = presenceChannel.presenceState()
// state é um objeto: key é user_id, value é um array de estados
renderOnlineUsers(Object.keys(state))
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// Novo usuário entrou
showToast(`${newPresences[0].user_name} entrou`)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// Usuário saiu
showToast(`${leftPresences[0].user_name} saiu`)
})
.subscribe()
// Registra o próprio estado ao ficar online
presenceChannel.track({
user_id: currentUser.id,
user_name: currentUser.name,
online_at: new Date().toISOString()
})
O método track() diz ao canal: “estou aqui”. O estado é sincronizado automaticamente com todos os assinantes e, por ser baseado em CRDT, você não precisa se preocupar com conflitos.
Canais privados: limitar quem pode assinar
Por padrão, qualquer pessoa com a anon key consegue assinar canais públicos. Mas alguns cenários exigem restrição de acesso, como um espaço colaborativo privado de uma equipe.
O Supabase permite controlar acesso a canais com RLS Policy:
-- Crie a Policy no Schema realtime
CREATE POLICY "Only team members can join private channel"
ON realtime.channels
FOR ALL
USING (
-- Verifica se o usuário pertence à equipe
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = channel.team_id
AND user_id = auth.uid()
)
);
Assim, só membros da equipe podem assinar o canal private-team-xxx; outras pessoas serão recusadas.
5. Produção: parâmetros de configuração que você precisa conhecer
Algo que roda bem no desenvolvimento local pode virar uma coleção de problemas depois do deploy. Muitas vezes, a causa está na configuração.
Alguns parâmetros-chave do servidor Realtime
A configuração padrão do Supabase Realtime atende boa parte dos projetos, mas cenários de alta concorrência precisam de ajuste:
| Parâmetro | Valor padrão | Sugestão | Função |
|---|---|---|---|
DB_POOL_SIZE | 10 | Ajuste conforme o número de conexões concorrentes | Tamanho do pool de conexões PostgreSQL |
DB_QUEUE_TARGET | 100ms | Reduzir pode diminuir latência, mas aumenta CPU | Tempo de espera para enviar mensagens em lote |
SUBSCRIBER_LIMIT | 200 | Ajuste conforme o volume de usuários | Máximo de assinantes por canal |
Se a latência das mensagens aumentar de forma perceptível, você pode reduzir DB_QUEUE_TARGET, por exemplo para 50ms. O custo é que o servidor passa a verificar mudanças com mais frequência, aumentando o uso de CPU.
Limites de conexão em arquitetura multi-tenant
Uma armadilha comum: em sistemas multi-tenant, criar um canal por tenant faz o número total de canais explodir rapidamente.
O Supabase Realtime limita o número total de assinaturas em um projeto. No plano Pro, são 5000 assinaturas concorrentes. Se seu sistema tem 1000 tenants e uma média de 5 pessoas online por tenant, você já fica no limite.
Soluções:
- Combinar canais: não é preciso criar um canal separado para cada tenant; dá para separar dentro de um único canal com
filter - Assinatura seletiva: o usuário assina apenas o canal do tenant atual, não todos
// Usa filter para receber apenas mensagens do tenant atual
supabase
.channel('tenant-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'tenant_id=eq.123' // Recebe apenas mensagens do tenant 123
},
handler
)
.subscribe()
Comparativo: Supabase vs Pusher vs Firebase
Para fechar, uma comparação rápida entre algumas soluções populares de tempo real:
| Solução | Custo | Recursos | Curva de aprendizado |
|---|---|---|---|
| Supabase Realtime | Grátis (Pro US$ 25/mês) | Alta (três em um + vínculo com banco) | Média |
| Pusher | A partir de US$ 29 | Média (WebSocket puro) | Baixa |
| Firebase Realtime DB | Cobrança por uso | Média (preso ao ecossistema Firebase) | Baixa |
A vantagem do Supabase é que Postgres Changes escuta mudanças do banco diretamente, sem exigir lógica extra de push; além disso, RLS entra em vigor automaticamente, mantendo a segurança unificada. A desvantagem é que você precisa entender alguns mecanismos do PostgreSQL, então a curva de aprendizado é um pouco mais íngreme.
Se você já usa Supabase para Auth e Storage, adicionar Realtime é bem natural. Se você só precisa de um WebSocket simples, talvez o Pusher seja mais rápido de colocar de pé.
Resumo
Depois de tudo isso, os pontos principais são três:
Escolha o recurso certo: Broadcast transmite eventos, Presence sincroniza estado, Postgres Changes persiste dados. Pergunte se o dado precisa ser persistido e se ele é evento ou estado. A resposta costuma aparecer.
Cuide da conexão: assinatura bem-sucedida não garante que mensagens continuarão chegando para sempre. Monitore mudanças de status ativamente, mostre ao usuário que a reconexão está em andamento e sincronize dados perdidos assim que a conexão voltar. Esses detalhes é que deixam a experiência de tempo real estável.
Ajuste a configuração: produção não é só uma versão ampliada do ambiente local. Parâmetros como DB_POOL_SIZE e QUEUE_TARGET afetam diretamente latência e throughput. Antes de lançar, pelo menos confira os valores padrão.
A primeira armadilha em que caí, o WebSocket cair sem ninguém perceber, foi resolvida depois com monitoramento de status e aviso de reconexão. A experiência melhorou na hora: quando a rede cai, o usuário vê “recuperando conexão” em vez de esperar no escuro; quando a conexão volta, as mensagens são completadas automaticamente, sem atualizar a página.
Se você ainda não usou Supabase Realtime, comece por Postgres Changes. É o caminho mais simples e também o cenário mais comum. Com os artigos anteriores da série de Auth, como verificação por e-mail e configuração de OAuth, já dá para montar um backend em tempo real completo.
Se tiver dúvidas, deixe um comentário ou vá direto à documentação oficial do Supabase. A parte de arquitetura está bem explicada; quem quiser se aprofundar em Phoenix Channels e no PG2 adapter pode ler o código-fonte.
FAQ
Qual é a diferença entre os três recursos do Supabase Realtime?
Como recuperar depois que o WebSocket cai?
• reconectar rápido nas primeiras tentativas (1 segundo)
• desacelerar depois (3 segundos)
• sincronizar imediatamente as mensagens perdidas quando a reconexão funcionar
As assinaturas Realtime seguem regras de RLS?
Quais parâmetros merecem atenção em produção?
• DB_POOL_SIZE: tamanho do pool de conexões PostgreSQL, padrão 10
• DB_QUEUE_TARGET: tempo de espera para envio em lote, padrão 100ms
• SUBSCRIBER_LIMIT: número máximo de assinantes por canal, padrão 200
Como evitar explosão de canais em sistemas multi-tenant?
Como o Supabase Realtime se compara a Pusher e Firebase?
16 min de leitura · Publicado em: 12 mai 2026 · Atualizado em: 14 jul 2026
Supabase na prática
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Supabase Realtime na prática: comparação entre três modos e desenvolvimento de aplicações colaborativas
O Supabase Realtime oferece três modos em tempo real: Postgres Changes, Presence e Broadcast. Este artigo compara as características de cada um e apresenta um exemplo completo de aplicação colaborativa, com código e configuração de segurança RLS.
Parte 5 de 10
Próximo
Supabase Storage na prática: upload de arquivos, CDN e controle de acesso
Guia prático completo do Supabase Storage: comparação entre três modelos de controle de acesso, upload em partes com TUS, técnicas de otimização do Smart CDN e análise de preços em relação ao R2 e ao S3. Inclui exemplos em React e soluções para problemas comuns.
Parte 7 de 10



Comentários
Entre com GitHub para comentar