Alternar tema

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

Easton editorial illustration: large WebSocket plug, reconnect loop

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.

RecursoArmazenamento dos dadosCenário típicoLatência
BroadcastSó memória, sem persistênciaMensagens entre clientes, sincronização da posição do mouseMais baixa
PresenceArmazenamento chave-valor em memória (CRDT)Lista de usuários online, sincronização de estado colaborativoBaixa
Postgres ChangesBanco PostgreSQLMensagens de chat, mudança de status de pedidoMé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:

  1. Os dados precisam ser persistidos?

    • Precisam ser persistidos -> Postgres Changes
    • Não precisam ser persistidos -> passe para a segunda pergunta
  2. 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 evento
  • payload.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:

StatusSignificadoO que fazer
SUBSCRIBEDAssinatura bem-sucedidaOperação normal, receber mensagens
CHANNEL_ERRORErro de conexãoRegistrar log e tentar reconectar
TIMED_OUTTimeout, sem respostaPode ser oscilação de rede; acione reconexão
CLOSEDConexão fechadaUsuá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âmetroValor padrãoSugestãoFunção
DB_POOL_SIZE10Ajuste conforme o número de conexões concorrentesTamanho do pool de conexões PostgreSQL
DB_QUEUE_TARGET100msReduzir pode diminuir latência, mas aumenta CPUTempo de espera para enviar mensagens em lote
SUBSCRIBER_LIMIT200Ajuste conforme o volume de usuáriosMá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çãoCustoRecursosCurva de aprendizado
Supabase RealtimeGrátis (Pro US$ 25/mês)Alta (três em um + vínculo com banco)Média
PusherA partir de US$ 29Média (WebSocket puro)Baixa
Firebase Realtime DBCobrança por usoMé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?
Broadcast serve para transmitir eventos entre clientes, como sincronização de cursor; Presence serve para sincronizar estado, como usuários online; Postgres Changes serve para escutar mudanças no banco de dados. A escolha passa por duas perguntas: os dados precisam ser persistidos? Eles representam um evento ou um estado?
Como recuperar depois que o WebSocket cai?
O Supabase usa reconexão com exponential backoff por padrão. Você também pode definir sua própria estratégia:

• 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?
Sim. Assinaturas Realtime também seguem regras de Row Level Security. O usuário só recebe mudanças que tem permissão para ver, então a lógica de segurança não precisa ser escrita duas vezes.
Quais parâmetros merecem atenção em produção?
Três parâmetros são importantes:

• 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?
Use o parâmetro filter para filtrar mensagens dentro de um único canal, em vez de criar um canal separado para cada tenant. Por exemplo, filter: "tenant_id=eq.123" recebe apenas mudanças de um tenant específico.
Como o Supabase Realtime se compara a Pusher e Firebase?
A vantagem do Supabase é escutar o banco diretamente com Postgres Changes e aplicar RLS automaticamente. A desvantagem é uma curva de aprendizado um pouco mais íngreme. Se você já usa Supabase Auth/Storage, Realtime encaixa bem; se só precisa de um WebSocket simples, Pusher pode ser mais rápido de adotar.

16 min de leitura · Publicado em: 12 mai 2026 · Atualizado em: 14 jul 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog