Construí três sistemas de leilão nos últimos dois anos. O primeiro foi uma bagunça — sondando o banco de dados a cada segundo, condições de corrida por toda parte, lances desaparecendo no vazio. O segundo foi melhor, mas exigiu gerenciar um servidor WebSocket separado junto com a API principal. O terceiro? É esse que vou te mostrar. Ele usa Supabase Realtime, e é a primeira vez que construir um mecanismo de leilão realmente se sentiu *certo*.

Supabase Realtime fica em cima do Write-Ahead Log (WAL) do PostgreSQL e usa um servidor baseado em Elixir para enviar mudanças de banco de dados por WebSockets para clientes conectados. Para um sistema de leilão, isso significa que cada lance que atinge seu banco de dados se propaga instantaneamente para cada licitante observando aquele leilão. Sem sondagem. Sem infraestrutura pub/sub separada. Seu banco de dados é seu sistema de eventos.

Vamos construir um do zero.

Sumário

Visão Geral da Arquitetura

Antes de escrever qualquer código, vamos entender o que estamos construindo e como as peças se encaixam.

Supabase Realtime oferece três primitivas que se mapeiam perfeitamente aos requisitos de leilão:

  • Postgres Changes: Subscreva eventos INSERT, UPDATE e DELETE nas suas tabelas de lances e leilões. Quando alguém faz um lance, cada assinante obtém os dados da nova linha em milissegundos.
  • Broadcast: Envie mensagens efêmeras para participantes do canal. Perfeito para notificações "você foi superado" que não precisam ser persistidas.
  • Presence: Rastreie quem está observando um leilão no momento. Isso permite mostrar "14 licitantes observando" na sua UI e detectar sessões fantasma.

O fluxo de dados fica assim:

  1. O licitante envia um lance através do seu frontend
  2. Uma chamada RPC ou inserção direta atinge sua tabela bids
  3. Um trigger PostgreSQL valida o valor do lance e atualiza auctions.current_high_bid
  4. Supabase Realtime pega a mudança de WAL e a envia para todos os assinantes no canal daquele leilão
  5. Um segundo trigger dispara um evento Broadcast para notificar o licitante anterior que foi superado
  6. Cada cliente conectado atualiza sua UI em tempo real

A latência de colocação de lance até atualização de UI em todos os clientes é tipicamente menor que 100ms. Medi p99 em torno de 80-90ms em produção no nível Pro da Supabase.

Por Que Não Apenas Usar Sondagem?

Sei que alguns estão pensando "não posso apenas sondar a cada 500ms?" Pode. Mas com 200 licitantes concorrentes em um único leilão, são 400 requisições por segundo atingindo seu banco de dados para um leilão. Multiplique por 50 leilões ativos e você está em 20.000 consultas por segundo — a maioria retornando nada novo. WebSockets invertem este modelo: zero consultas quando nada muda, atualizações instantâneas quando algo muda.

Schema do Banco de Dados e Configuração

Aqui está o schema que uso. É deliberadamente simples — você pode estendê-lo, mas a estrutura principal lida com a maioria dos tipos de leilão.

-- Tabela de Leilões
CREATE TABLE auctions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  item_name TEXT NOT NULL,
  description TEXT,
  starting_price DECIMAL(12,2) NOT NULL DEFAULT 0,
  current_high_bid DECIMAL(12,2) DEFAULT 0,
  highest_bidder_id UUID REFERENCES auth.users(id),
  min_increment DECIMAL(12,2) DEFAULT 1.00,
  status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('scheduled', 'active', 'ended', 'sold', 'cancelled')),
  starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  ends_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 minutes',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Tabela de Lances
CREATE TABLE bids (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  auction_id UUID NOT NULL REFERENCES auctions(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES auth.users(id),
  amount DECIMAL(12,2) NOT NULL,
  placed_at TIMESTAMPTZ DEFAULT NOW(),
  CONSTRAINT positive_amount CHECK (amount > 0)
);

-- Índice para buscas rápidas de lances por leilão
CREATE INDEX idx_bids_auction_amount ON bids(auction_id, amount DESC);
CREATE INDEX idx_bids_auction_time ON bids(auction_id, placed_at DESC);

-- Crítico: ativar replica identity para Realtime
ALTER TABLE auctions REPLICA IDENTITY FULL;
ALTER TABLE bids REPLICA IDENTITY FULL;

A configuração REPLICA IDENTITY FULL é essencial. Sem ela, Supabase Realtime obtém apenas a chave primária em eventos UPDATE e DELETE — não o payload completo. Para um sistema de leilão, você precisa do payload completo para que os clientes atualizem valores de lances sem fazer uma consulta separada.

Habilitando Replicação Realtime

No Painel da Supabase, vá para Database → Replication e ative a replicação para ambas as tabelas auctions e bids. Alternativamente, você pode fazer isso com SQL:

BEGIN;
  -- Remova a publicação existente se existir
  DROP PUBLICATION IF EXISTS supabase_realtime;
  
  -- Crie publicação com ambas as tabelas
  CREATE PUBLICATION supabase_realtime FOR TABLE auctions, bids;
COMMIT;

Segurança em Nível de Linha

Não pule isto. RLS é sua camada de validação do lado do servidor.

ALTER TABLE auctions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bids ENABLE ROW LEVEL SECURITY;

-- Qualquer pessoa pode ver leilões ativos
CREATE POLICY "Public auction viewing" ON auctions
  FOR SELECT USING (status IN ('active', 'ended', 'sold'));

-- Usuários autenticados podem ver todos os lances em leilões ativos
CREATE POLICY "View bids on active auctions" ON bids
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM auctions
      WHERE auctions.id = bids.auction_id
      AND auctions.status = 'active'
    )
  );

-- Apenas usuários autenticados podem fazer lances
CREATE POLICY "Place bids" ON bids
  FOR INSERT WITH CHECK (
    auth.uid() = user_id
    AND EXISTS (
      SELECT 1 FROM auctions
      WHERE auctions.id = auction_id
      AND auctions.status = 'active'
      AND auctions.ends_at > NOW()
    )
  );

Triggers do PostgreSQL para Lógica de Lances

É aqui que a mágica real acontece. O banco de dados impõe toda a lógica de lances do lado do servidor — o cliente não pode trapacear.

Trigger de Validação de Lance e Atualização de Leilão

CREATE OR REPLACE FUNCTION process_new_bid()
RETURNS TRIGGER AS $$
DECLARE
  v_auction auctions%ROWTYPE;
BEGIN
  -- Bloquear a linha do leilão para prevenir condições de corrida
  SELECT * INTO v_auction
  FROM auctions
  WHERE id = NEW.auction_id
  FOR UPDATE;

  -- Validar se o leilão está ativo
  IF v_auction.status != 'active' THEN
    RAISE EXCEPTION 'Auction is not active';
  END IF;

  -- Validar se o leilão não expirou
  IF v_auction.ends_at < NOW() THEN
    RAISE EXCEPTION 'Auction has ended';
  END IF;

  -- Validar se o valor do lance excede o lance mais alto + incremento mínimo
  IF NEW.amount < v_auction.current_high_bid + v_auction.min_increment THEN
    RAISE EXCEPTION 'Bid must be at least % higher than current high bid of %',
      v_auction.min_increment, v_auction.current_high_bid;
  END IF;

  -- Evitar auto-superação
  IF v_auction.highest_bidder_id = NEW.user_id THEN
    RAISE EXCEPTION 'You are already the highest bidder';
  END IF;

  -- Atualizar leilão com novo lance mais alto
  UPDATE auctions
  SET
    current_high_bid = NEW.amount,
    highest_bidder_id = NEW.user_id,
    updated_at = NOW()
  WHERE id = NEW.auction_id;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER validate_and_process_bid
  BEFORE INSERT ON bids
  FOR EACH ROW
  EXECUTE FUNCTION process_new_bid();

Aquele bloqueio FOR UPDATE na linha do leilão é crítico. Sem ele, dois lances chegando simultaneamente poderiam ler o mesmo current_high_bid, ambos passar validação e ambos serem inseridos. O bloqueio serializa o acesso.

Broadcast de Notificações de Superação

Este trigger dispara depois de um lance bem-sucedido e envia uma notificação efêmera para o canal do leilão:

CREATE OR REPLACE FUNCTION notify_outbid()
RETURNS TRIGGER AS $$
DECLARE
  v_previous_bidder UUID;
BEGIN
  -- Encontre quem acabou de ser superado
  SELECT user_id INTO v_previous_bidder
  FROM bids
  WHERE auction_id = NEW.auction_id
    AND id != NEW.id
  ORDER BY amount DESC
  LIMIT 1;

  -- Broadcast notificação de superação se houve um licitante anterior
  IF v_previous_bidder IS NOT NULL THEN
    PERFORM realtime.send(
      jsonb_build_object(
        'auction_id', NEW.auction_id,
        'new_high', NEW.amount,
        'outbid_user', v_previous_bidder,
        'new_leader', NEW.user_id
      ),
      'outbid',
      'auction:' || NEW.auction_id::text,
      true
    );
  END IF;

  RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER after_bid_notify
  AFTER INSERT ON bids
  FOR EACH ROW
  EXECUTE FUNCTION notify_outbid();

Subscrição Cliente com JavaScript

Agora vamos conectar o frontend. Vou mostrar isto com padrões vanilla JavaScript/React — a mesma abordagem funciona se você está construindo com Next.js ou qualquer outro framework.

Inicializar o Cliente

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  {
    realtime: {
      params: {
        eventsPerSecond: 20 // Acelerar para tráfego de leilão
      }
    }
  }
);

Aquele parâmetro eventsPerSecond importa. Em um leilão acirrado com dezenas de lances por segundo, você não quer re-renderizar 50 vezes por segundo. Vinte atualizações por segundo é mais que suficiente para uma UI suave.

Subscrever a um Canal de Leilão

function subscribeToAuction(auctionId, callbacks) {
  const channel = supabase.channel(`auction:${auctionId}`);

  channel
    // Ouça novos lances via Postgres Changes
    .on('postgres_changes', {
      event: 'INSERT',
      schema: 'public',
      table: 'bids',
      filter: `auction_id=eq.${auctionId}`
    }, (payload) => {
      callbacks.onNewBid(payload.new);
    })

    // Ouça mudanças de status do leilão
    .on('postgres_changes', {
      event: 'UPDATE',
      schema: 'public',
      table: 'auctions',
      filter: `id=eq.${auctionId}`
    }, (payload) => {
      callbacks.onAuctionUpdate(payload.new);
    })

    // Ouça notificações broadcast de superação
    .on('broadcast', { event: 'outbid' }, ({ payload }) => {
      callbacks.onOutbid(payload);
    })

    // Rastreie licitantes ativos via Presence
    .on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState();
      const bidderCount = Object.keys(state).length;
      callbacks.onPresenceUpdate(bidderCount, state);
    })

    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        // Rastreie a presença deste usuário
        await channel.track({
          user_id: supabase.auth.getUser()?.data?.user?.id,
          status: 'watching',
          joined_at: new Date().toISOString()
        });
      }
    });

  return channel;
}

React Hook para Subscrições de Leilão

import { useState, useEffect, useCallback } from 'react';

function useAuction(auctionId) {
  const [auction, setAuction] = useState(null);
  const [bids, setBids] = useState([]);
  const [bidderCount, setBidderCount] = useState(0);
  const [isOutbid, setIsOutbid] = useState(false);

  useEffect(() => {
    // Busque estado inicial
    async function loadAuction() {
      const { data: auctionData } = await supabase
        .from('auctions')
        .select('*')
        .eq('id', auctionId)
        .single();
      setAuction(auctionData);

      const { data: bidData } = await supabase
        .from('bids')
        .select('*')
        .eq('auction_id', auctionId)
        .order('amount', { ascending: false })
        .limit(20);
      setBids(bidData || []);
    }
    loadAuction();

    // Subscreva atualizações em tempo real
    const channel = subscribeToAuction(auctionId, {
      onNewBid: (bid) => {
        setBids(prev => [bid, ...prev].slice(0, 20));
        setIsOutbid(false);
      },
      onAuctionUpdate: (updated) => setAuction(updated),
      onOutbid: (payload) => {
        const currentUser = supabase.auth.getUser()?.data?.user;
        if (payload.outbid_user === currentUser?.id) {
          setIsOutbid(true);
        }
      },
      onPresenceUpdate: (count) => setBidderCount(count)
    });

    return () => {
      supabase.removeChannel(channel);
    };
  }, [auctionId]);

  const placeBid = useCallback(async (amount) => {
    const user = (await supabase.auth.getUser()).data.user;
    const { data, error } = await supabase
      .from('bids')
      .insert({
        auction_id: auctionId,
        amount: parseFloat(amount),
        user_id: user.id
      })
      .select()
      .single();

    if (error) throw new Error(error.message);
    return data;
  }, [auctionId]);

  return { auction, bids, bidderCount, isOutbid, placeBid };
}

Tratando Condições de Corrida e Validação de Lances

Condições de corrida são a maior fonte de bugs em sistemas de leilão. Aqui está como eu lido com elas.

Do Lado do Servidor: PostgreSQL Faz o Trabalho Pesado

O SELECT ... FOR UPDATE na nossa função trigger é a primeira linha de defesa. Mas há outro padrão que comecei a usar — bloqueios consultivos para leilões de alta contenção:

CREATE OR REPLACE FUNCTION place_bid_safe(
  p_auction_id UUID,
  p_user_id UUID,
  p_amount DECIMAL
)
RETURNS TABLE(bid_id UUID, new_high DECIMAL) AS $$
DECLARE
  v_lock_key BIGINT;
  v_bid_id UUID;
BEGIN
  -- Gere uma chave de bloqueio determinística a partir do UUID do leilão
  v_lock_key := ('x' || left(p_auction_id::text, 15))::bit(64)::bigint;
  
  -- Adquira bloqueio consultivo (bloqueia lances concorrentes no mesmo leilão)
  PERFORM pg_advisory_xact_lock(v_lock_key);

  -- Agora seguro para inserir (trigger lida com validação)
  INSERT INTO bids (auction_id, user_id, amount)
  VALUES (p_auction_id, p_user_id, p_amount)
  RETURNING id INTO v_bid_id;

  RETURN QUERY
  SELECT v_bid_id, p_amount;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Chame isto pelo cliente usando RPC da Supabase:

const { data, error } = await supabase.rpc('place_bid_safe', {
  p_auction_id: auctionId,
  p_user_id: user.id,
  p_amount: bidAmount
});

Do Lado do Cliente: UI Otimista com Reversão

Mostre o lance imediatamente na UI, mas esteja pronto para revertê-lo se o servidor rejeitá-lo:

async function handleBidSubmit(amount) {
  const optimisticBid = {
    id: crypto.randomUUID(),
    amount,
    user_id: user.id,
    placed_at: new Date().toISOString(),
    _optimistic: true
  };

  // Mostre imediatamente
  setBids(prev => [optimisticBid, ...prev]);

  try {
    await placeBid(amount);
    // Lance real chegará via Realtime e substituirá o otimista
  } catch (err) {
    // Remova lance otimista em caso de falha
    setBids(prev => prev.filter(b => b.id !== optimisticBid.id));
    showError(err.message);
  }
}

Rastreamento de Presença para Licitantes Ativos

Mostrar quantas pessoas estão observando um leilão cria urgência. Rastreamento de presença é bem simples com Supabase:

// Atualize o status do usuário quando começar a fazer lances
async function updatePresenceStatus(channel, status) {
  await channel.track({
    user_id: user.id,
    status, // 'watching', 'bidding', 'won'
    last_active: new Date().toISOString()
  });
}

No lado da exibição, você pode analisar o estado de presença para mostrar quantos estão ativamente lançando vs. apenas observando:

function parseBidderStats(presenceState) {
  const users = Object.values(presenceState).flat();
  return {
    total: users.length,
    bidding: users.filter(u => u.status === 'bidding').length,
    watching: users.filter(u => u.status === 'watching').length
  };
}

Ajuste de Performance e Considerações de Produção

Aceleração e Debouncing

Uma guerra de lances pode gerar dezenas de eventos por segundo. Aqui está o que configuro:

  • Lado do servidor: eventsPerSecond: 20 na configuração do cliente Supabase
  • Lado do cliente: Debounce do botão de lance em 300ms para evitar cliques duplos
  • Atualizações de UI: Use requestAnimationFrame para animações de lista de lances

Tempo de Encerramento de Leilão

Não confie no relógio do cliente. Use um trabalho cron do PostgreSQL via pg_cron:

-- Execute a cada 10 segundos para fechar leilões expirados
SELECT cron.schedule(
  'close-expired-auctions',
  '*/10 * * * * *',
  $$
  UPDATE auctions
  SET status = CASE
    WHEN highest_bidder_id IS NOT NULL THEN 'sold'
    ELSE 'ended'
  END
  WHERE status = 'active'
  AND ends_at <= NOW();
  $$
);

Extensão Anti-Snipe

A maioria das plataformas de leilão estende o prazo se um lance chegar durante os últimos segundos:

-- Adicione ao trigger process_new_bid
IF v_auction.ends_at - NOW() < INTERVAL '30 seconds' THEN
  UPDATE auctions
  SET ends_at = ends_at + INTERVAL '30 seconds'
  WHERE id = NEW.auction_id;
END IF;

Supabase Realtime vs Alternativas

Usei a maioria destes em produção. Aqui está uma comparação honesta:

Recurso Supabase Realtime Pusher Ably Firebase RTDB Socket.io (auto-hospedado)
Sincronização de BD nativa ✅ PostgreSQL WAL ❌ Serviço separado ❌ Serviço separado ✅ Árvore JSON ❌ Manual
Latência (p99) ~80-100ms ~60ms ~50ms ~100ms ~40ms (depende da infra)
Máx eventos/seg 200k+ 10k (Pro) 50k 100k Ilimitado (você dimensiona)
Integração de Auth Integrada (RLS + JWT) Customizada Baseada em token Firebase Auth Customizada
Presence ✅ Integrada ✅ Integrada ✅ Integrada ✅ Integrada ✅ Integrada
Nível gratuito 500K MAU, 200 concorrentes 100 conexões 6M msgs/mês 1GB armazenado $0 (custos de hospedagem)
Preço Pro $25/mês $49/mês $29/mês Pagamento conforme uso ~$100-500/mês (AWS)
Melhor para Aplicações real-time cêntricas em BD Pub/sub simples Alta confiabilidade Aplicativos móveis Controle total

Para um sistema de leilão especificamente, Supabase vence porque seus lances já estão no PostgreSQL. Você não precisa sincronizar entre um banco de dados e um sistema pub/sub separado. O lance atinge o BD, o BD dispara o push WebSocket. Uma única fonte de verdade.

Se você está construindo em uma arquitetura de CMS headless, Supabase se encaixa naturalmente ao lado da entrega de conteúdo sem adicionar outro serviço para gerenciar.

Implantando e Escalando Seu Sistema de Leilão

Para a maioria dos projetos, o nível Pro gerenciado da Supabase a $25/mês lida confortavelmente com até 10.000 leilões diários. Aqui está o que observar:

  • Limites de conexão: O nível Pro oferece 500 conexões concorrentes de Realtime. Se você precisar de mais, será necessário fazer upgrade ou implementar pooling de conexão no cliente.
  • Tamanho do WAL: Lances de alto volume geram tráfego significativo de WAL. Monitore seu slot de replicação para evitar inchaço de disco.
  • Contagem de canais: Cada leilão obtém seu próprio canal. Com milhares de leilões ativos, teste que seu cliente se desinscreve adequadamente de leilões encerrados.

Para um frontend construído com Astro ou Next.js, o cliente JS da Supabase funciona identicamente — apenas certifique-se de que você está o inicializando do lado do cliente para subscrições Realtime.

Se você está construindo algo que precisa lidar com escala séria — centenas de milhares de licitantes concorrentes — entre em contato conosco. Arquitetamos esses sistemas em escala e podemos ajudá-lo a evitar armadilhas. Você também pode verificar nossa página de preços para contratos baseados em projetos.

FAQ

Quantos licitantes concorrentes o Supabase Realtime pode lidar? Supabase Realtime pode lidar com mais de 200.000 eventos por segundo em servidores distribuídos em sua plataforma gerenciada. O nível Pro a $25/mês oferece suporte a até 500 conexões concorrentes por projeto. Para leilões maiores, o nível Enterprise oferece limites customizados, ou você pode auto-hospedar o servidor Realtime (é código aberto) em sua própria infraestrutura.

Supabase Realtime é rápido o suficiente para um leilão ao vivo? Sim. Nos meus testes, a latência ponta a ponta de inserção de lance até notificação de cliente é em média de 50-80ms, com p99 abaixo de 100ms. Para contexto, um tempo de reação humano é cerca de 200-300ms, então lances aparecem efetivamente instantâneos. O gargalo raramente é Supabase — geralmente é a conexão de rede do cliente.

Como evito condições de corrida quando duas pessoas lançam simultaneamente? Use o bloqueio de nível de linha SELECT ... FOR UPDATE do PostgreSQL dentro de uma função trigger, ou use bloqueios consultivos via pg_advisory_xact_lock(). Isto serializa o processamento de lances por leilão para que apenas um lance seja validado por vez. O lance "perdedor" ainda é validado — ele apenas vê o lance mais alto atualizado do vencedor e seja tem sucesso (se ainda for maior) ou falha com um erro apropriado.

Posso usar Supabase Realtime com Next.js ou Astro? Absolutamente. O cliente @supabase/supabase-js funciona em qualquer ambiente JavaScript. Para Next.js, inicialize o cliente Supabase em um componente cliente (já que Realtime precisa de WebSockets do navegador) e use-o dentro de hooks useEffect. Para Astro, use-o em ilhas interativas do lado do cliente. O código de subscrição é idêntico independentemente de sua escolha de framework.

O que acontece se a conexão de um usuário cair no meio do leilão? Supabase Realtime tenta reconexão automaticamente. Quando o cliente reconecta e reinscreve-se, recebe o estado atual. Para leilões críticos, recomendo também buscar o estado de leilão mais recente via consulta padrão na reconexão para garantir que nada foi perdido durante a janela de desconexão. O sistema de Presence removerá automaticamente o usuário desconectado após um timeout.

Como lido com tempos de encerramento de leilão com precisão? Nunca confie em cronômetros do lado do cliente para tempos de encerramento de leilão — eles podem ser manipulados. Use a extensão pg_cron do PostgreSQL para verificar e fechar leilões expirados a cada 10 segundos do lado do servidor. Envie o timestamp do servidor para clientes para que possam exibir uma contagem regressiva, mas a determinação de encerramento real sempre acontece no banco de dados.

Supabase Realtime é gratuito para pequenos projetos? O nível gratuito da Supabase inclui Realtime com até 200 conexões concorrentes e 500.000 usuários ativos mensais. Isso é suficiente para um site de leilão hobista ou um MVP. Se você está executando uma plataforma de leilão de produção com tráfego significativo, o nível Pro a $25/mês com saída de $0.09/GB é onde você vai querer começar. É significativamente mais barato que executar sua própria infraestrutura de WebSocket.

Como testo um sistema de leilão com tempo real localmente? Use o CLI Supabase (supabase start) para executar uma instância Supabase local com Realtime ativado. Abra múltiplas abas do navegador para simular múltiplos licitantes. Para teste de carga, uso um script Node.js simples que cria 100+ clientes Supabase e os tem lançando um contra o outro em um cronômetro. Isto detecta condições de corrida e ajuda você a sintonizar seu parâmetro eventsPerSecond antes de ir para produção.