Saída em cache do Supabase: -90% de largura de banda em 2026
Uma manhã, abri meu painel do Supabase e quase cuspi meu café. O egress — a largura de banda de saída do meu banco de dados — estava subindo muito mais rápido do que o número de usuários do TAMSIV. Cada abertura do aplicativo disparava dezenas de requisições. E cada requisição significa dados em trânsito. Multiplique isso por centenas de usuários que abrem o aplicativo várias vezes ao dia, e você terá uma conta que dói.
Sou um desenvolvedor solo. Não tenho um orçamento de infraestrutura ilimitado. Cada euro conta. Então, arregaçei as mangas e procurei maneiras de reduzir drasticamente esse consumo sem degradar a experiência do usuário. Spoiler: consegui reduzir o egress em 80 a 90%. E, como bônus, o aplicativo ficou mais rápido.
Pontos chave a reter:
- O problema N+1 pode explodir sua largura de banda sem que você perceba
- Um cache de dois níveis (memória + persistente) elimina a maioria das requisições desnecessárias
- Supabase Realtime permite invalidar o cache em tempo real sem polling
- O batching de requisições transforma 20 chamadas em apenas 1
- Otimizar custos e otimizar a UX é frequentemente o mesmo trabalho
Por que o egress do Supabase explode em um aplicativo móvel?
Antes de falar sobre a solução, precisamos entender o problema. O Supabase, como qualquer serviço em nuvem, cobra pela largura de banda de saída. Cada vez que seu aplicativo faz uma requisição ao banco de dados, os dados transitam do servidor para o cliente. Isso é o egress.
Em um aplicativo web clássico, o usuário carrega uma página e pronto. Em um aplicativo móvel, é diferente. O usuário navega entre as abas, faz pull-to-refresh, abre uma tarefa, volta para o feed, abre outra tarefa. Cada navegação dispara requisições. E se seu código não estiver otimizado, cada requisição recarrega todos os dados, mesmo que nada tenha mudado nos últimos 30 segundos.
No TAMSIV, eu tinha dois problemas principais que multiplicavam o egress por um fator enorme.
O que é o problema N+1 e como detectá-lo?
O primeiro culpado foi o clássico problema N+1. No feed do TAMSIV, eu exibia uma lista de tarefas com suas estatísticas de visualização (quem viu a tarefa, quando, quantas vezes). Para cada tarefa, eu fazia uma chamada individual para recuperar suas ViewStats.
20 tarefas exibidas = 20 requisições individuais. 50 tarefas = 50 requisições. Você vê o problema. Cada requisição tem um custo fixo em termos de latência de rede e dados transferidos (cabeçalhos HTTP, metadados de resposta, etc.). Multiplique isso pelo número de tarefas, e o egress explode.
O pior é que esse padrão é invisível se você não olhar suas métricas. O aplicativo funciona. Os dados são exibidos. Tudo parece normal. Mas, em segundo plano, você está fazendo 20 vezes mais requisições do que o necessário.
Como detectá-lo? No Supabase, vá para o painel, seção Reports > API. Observe o número de requisições por endpoint. Se você vir um endpoint sendo chamado dezenas de vezes no mesmo segundo, é um N+1. Você também pode usar as ferramentas de inspeção do Supabase para analisar requisições lentas.
Como funciona o batching de requisições com o Supabase?
A solução para o N+1 é simples em teoria: em vez de fazer N requisições individuais, você faz uma única que recupera todos os dados de uma vez. Isso é o batching.
Criei uma função RPC no Supabase, getTaskViewStatsBatch(taskIds), que recebe um array de IDs como parâmetro e retorna as estatísticas de todas as tarefas em uma única requisição. O mesmo para os memorandos com getMemoViewStatsBatch(memoIds).
-- Antes: 20 chamadas individuais
SELECT * FROM view_stats WHERE task_id = 'xxx';
-- x 20 vezes...
-- Depois: 1 única chamada
SELECT * FROM view_stats WHERE task_id = ANY($1);
-- $1 = array de 20 IDs
O resultado é imediato: 20 requisições se tornam 1. O egress para esta operação é dividido por um fator significativo — não exatamente por 20, pois os dados em si não mudaram, mas a sobrecarga de rede (cabeçalhos, handshake, etc.) é eliminada 19 vezes em 20.
Se você usa as funções RPC do Supabase, o batching é trivial de implementar. O operador ANY() do PostgreSQL é seu melhor amigo.
O que é o ContentCacheService e por que precisamos dele?
O batching resolveu o problema N+1, mas ainda restava o segundo culpado: os dados recarregados integralmente a cada navegação, mesmo que nada tivesse mudado.
Quando o usuário abre o feed, as tarefas são carregadas do Supabase. Quando ele vai para a aba Agenda e depois volta para o feed, as tarefas são recarregadas do Supabase. Os mesmos dados, a mesma resposta, o mesmo custo de egress. Para nada.
A solução: um cache inteligente. Eu projetei o ContentCacheService, um singleton que gerencia o cache de todos os dados de conteúdo no TAMSIV. Seu princípio é simples: nunca refazer uma requisição se os dados não mudaram.
Como implementar um cache de dois níveis em um aplicativo React Native?
O ContentCacheService usa um cache de dois níveis, cada um com uma função precisa:
- L1 — Cache de memória (Map JavaScript): Acesso instantâneo, latência zero. Os dados estão na RAM. Quando o usuário navega entre as abas, o feed é exibido do L1 sem nenhuma requisição. O problema: os dados desaparecem quando o aplicativo é fechado.
- L2 — AsyncStorage: Cache persistente no dispositivo. Mais lento que o L1 (alguns milissegundos de leitura), mas sobrevive a reinícios do aplicativo. Quando o usuário reabre o TAMSIV, os dados do L2 são carregados no L1 e o feed é exibido imediatamente, antes mesmo que a primeira requisição do Supabase seja enviada.
O fluxo de leitura é o seguinte:
- Procurar no L1 (Map em memória). Se encontrado e não expirado → retornar imediatamente.
- Caso contrário, procurar no L2 (AsyncStorage). Se encontrado e não expirado → copiar para L1 e retornar.
- Caso contrário, requisição ao Supabase → armazenar em L1 e L2 → retornar.
Cada entrada do cache tem um timestamp. Um TTL (Time To Live) configurável determina quando uma entrada é considerada obsoleta. Mas o verdadeiro game-changer é a invalidação em tempo real com o Supabase Realtime.
Como o Supabase Realtime elimina o polling?
O problema clássico do cache é a invalidação. Como saber que os dados mudaram sem refazer a requisição? A solução ingênua é o polling: verificar a cada X segundos se algo mudou. Mas o polling é um desperdício — você faz requisições desnecessárias 90% do tempo.
O Supabase Realtime resolve esse problema elegantemente. É um sistema de subscrição em tempo real baseado em notificações PostgreSQL. Você se inscreve em uma tabela, e o Supabase envia um evento toda vez que uma linha é criada, modificada ou excluída.
No TAMSIV, configurei dois canais:
content-cache-tasks: escuta as mudanças emprivat.taskscontent-cache-memos: escuta as mudanças emprivat.memos
Quando uma mudança é detectada, o ContentCacheService invalida a entrada correspondente no L1 e no L2. Na próxima renderização do componente React, os dados são recarregados do Supabase e colocados em cache novamente. Os componentes que escutam as mudanças via listeners são automaticamente re-renderizados com os novos dados.
O resultado: zero polling, zero requisição desnecessária. Os dados estão sempre atualizados sem desperdiçar largura de banda. É exatamente o padrão que também uso no feed de gamificação e nos grupos colaborativos.
Qual é o impacto real nos custos e no desempenho?
Os números falam por si. Após a implementação do ContentCacheService + batching:
- Egress reduzido em 80 a 90% dependendo dos períodos e do número de usuários ativos
- Número de requisições API dividido por 15 a 20 graças ao batching + cache
- Tempo de exibição do feed: quase instantâneo do cache L1, contra 200-500ms antes
- Recarregamento após o fechamento: ~50ms do cache L2, contra 300-800ms do Supabase
O bônus inesperado: a UX melhorou consideravelmente. O feed é exibido instantaneamente, as transições entre as abas são fluidas, e o pull-to-refresh se tornou uma verdadeira atualização (que recarrega apenas o que mudou) em vez de um recarregamento completo.
Como aplicar essa estratégia ao seu próprio projeto?
Se você usa Supabase (ou qualquer backend em nuvem) e seu egress começa a subir, aqui está a abordagem que recomendo:
- Audite suas requisições: Use o painel do Supabase ou uma ferramenta de monitoramento para identificar os endpoints mais chamados. Procure por padrões N+1.
- Faça batching de requisições repetitivas: Tudo o que faz uma requisição por elemento em uma lista deve ser convertido em uma única requisição com um array de IDs.
- Implemente um cache de dois níveis: Memória para velocidade, armazenamento persistente para sobreviver a reinícios.
- Use Realtime para invalidação: Sem polling. Os dados avisam quando mudam.
- Meça antes e depois: Sem métricas, você não sabe se sua otimização realmente funcionou.
Essa abordagem não é específica para React Native ou Supabase. O padrão cache L1/L2 + invalidação baseada em eventos funciona com Firebase, AWS AppSync, ou qualquer backend que suporte notificações em tempo real.
Quais erros evitar ao otimizar o cache?
Cometi alguns erros ao longo do caminho. Aqui está o que aprendi:
- Não armazenar dados sensíveis no AsyncStorage: O AsyncStorage não é criptografado por padrão no Android. Para dados sensíveis, use um armazenamento seguro. No meu caso, as tarefas e memorandos não são dados críticos em si (os tokens de autenticação estão em um keychain seguro).
- Gerenciar o tamanho do cache: Sem limite, o cache L2 pode crescer indefinidamente. Implementei uma política de remoção LRU (Least Recently Used) e um tamanho máximo.
- Atenção às URLs assinadas: As imagens geradas por IA no TAMSIV usam URLs assinadas do Supabase que expiram após 1 hora. O cache deve armazenar o
storage_pathe regenerar a URL conforme necessário, não armazenar a URL assinada em si. - Testar o cache vazio: A experiência do primeiro lançamento (cache vazio) deve permanecer correta. Não presuma que o cache sempre conterá dados.
Como o cache interage com os outros serviços do TAMSIV?
O ContentCacheService não está isolado. Ele interage com vários outros componentes da arquitetura do TAMSIV:
- GamificationService: Usa o cache para exibir pontos, distintivos e sequências sem requisições adicionais. O método
refreshFeedImageUrls()regenera as URLs assinadas expiradas. - Pipeline de voz: Quando a IA cria uma tarefa via gravador de voz, o evento Realtime invalida o cache e o feed é atualizado automaticamente.
- Agenda colaborativa: Os eventos compartilhados usam o mesmo padrão de cache com invalidação Realtime.
- Pesquisa: O SearchService consulta primeiro o cache L1 antes de iniciar uma requisição ao Supabase, o que torna a pesquisa quase instantânea para dados já carregados.
Este sistema de cache tornou-se o pilar invisível do desempenho do TAMSIV. O usuário nunca o vê diretamente, mas o sente em cada interação.
FAQ
O egress do Supabase é realmente um problema para pequenos projetos?
O plano gratuito do Supabase inclui uma cota generosa de egress. Mas se seu aplicativo faz muitas requisições repetitivas (o que é comum em dispositivos móveis), você pode atingir o limite mais rápido do que o esperado. É melhor otimizar cedo do que descobrir o problema em pleno crescimento.
O cache não corre o risco de exibir dados obsoletos?
Essa é a grande vantagem do Supabase Realtime. Assim que um dado muda no banco, um evento é enviado ao cliente que invalida o cache. Na prática, o atraso entre a modificação e a atualização no lado do cliente é da ordem de um segundo. Para um aplicativo de produtividade como o TAMSIV, isso é imperceptível.
Por que não usar React Query ou SWR em vez disso?
React Query e SWR são excelentes bibliotecas para cache de requisições na web. Mas em um contexto React Native com AsyncStorage como cache persistente e Supabase Realtime para invalidação, um serviço sob medida oferece mais controle. O ContentCacheService gerencia os dois níveis de cache, o TTL, a remoção e a invalidação Realtime em um único singleton coerente.
Esse padrão funciona com outros bancos de dados além do Supabase?
O princípio é universal. O cache L1/L2 é independente do backend. Para a invalidação em tempo real, é necessário um equivalente às notificações PostgreSQL: Firebase Realtime Database, assinaturas AWS AppSync, ou até mesmo um WebSocket caseiro simples. O importante é evitar o polling.
Qual é o custo de manutenção desse sistema de cache?
Uma vez implementado, o ContentCacheService é muito estável. Praticamente não o toquei desde sua implementação, exceto para adicionar novas entidades ao cache (eventos da agenda, por exemplo). O código é um singleton com uma API clara — os outros serviços só precisam chamar get() e invalidate().