Estratégias de cache no Flutter: o que armazenar localmente, quando invalidar e como manter telas consistentes entre navegações.

Cache em um app móvel significa manter uma cópia dos dados por perto (na memória ou no dispositivo) para que a próxima tela renderize instantaneamente em vez de esperar pela rede. Esses dados podem ser uma lista de itens, um perfil de usuário ou resultados de busca.
O problema é que dados em cache frequentemente ficam um pouco errados. Os usuários percebem rápido: um preço que não atualiza, um contador de badges que parece preso, ou uma tela de detalhes mostrando informação antiga logo depois que foi alterada. O que torna isso difícil de depurar é o tempo. O mesmo endpoint pode parecer correto após um pull-to-refresh, mas errado depois de navegar para trás, ao retomar o app ou ao trocar de conta.
Há uma troca real. Se você sempre buscar dados frescos, as telas ficam lentas e saltam, e você gasta bateria e dados. Se você cachear agressivamente, o app parece rápido, mas as pessoas deixam de confiar no que veem.
Um objetivo simples ajuda: torne a frescura previsível. Decida o que cada tela pode mostrar (fresco, levemente desatualizado ou offline), quanto tempo os dados podem viver antes de serem atualizados e quais eventos devem invalidá-los.
Imagine um fluxo comum: o usuário abre um pedido e volta para a lista de pedidos. Se a lista vier do cache, pode mostrar o status antigo. Se você atualizar sempre, a lista pode piscar e parecer lenta. Regras claras como “mostrar cache instantaneamente, atualizar em segundo plano e atualizar ambas as telas quando a resposta chegar” tornam a experiência consistente na navegação.
Um cache não é só “dados salvos.” É uma cópia salva mais uma regra de quando essa cópia ainda é válida. Se você armazena o payload mas ignora a regra, acaba com duas realidades: uma tela mostra informação nova, outra mostra a de ontem.
Um modelo prático é colocar cada item em cache em um de três estados:
Esse enquadramento mantém sua UI previsível porque ela pode reagir da mesma forma sempre que encontrar um dado estado.
Regras de frescor devem basear-se em sinais que você consiga explicar a um colega. Escolhas comuns são expiração por tempo (por exemplo, 5 minutos), mudança de versão (esquema ou versão do app), ação do usuário (pull to refresh, submeter, deletar) ou dica do servidor (ETag, timestamp last-updated ou uma resposta explícita de “cache invalido”).
Exemplo: uma tela de perfil carrega dados do usuário em cache instantaneamente. Se estiver desatualizado mas utilizável, mostra o nome e avatar em cache e depois atualiza silenciosamente. Se o usuário acabou de editar o perfil, esse é um momento de atualização obrigatória. O app deve atualizar o cache imediatamente para que todas as telas fiquem consistentes.
Decida quem é dono dessas regras. Na maioria dos apps, o melhor padrão é: a camada de dados é dona das regras de frescor e invalidação, a UI só reage (mostrar cache, mostrar carregando, mostrar erro), e o backend fornece pistas quando puder. Isso evita que cada tela invente suas próprias regras.
Um bom cache começa com uma pergunta: se esses dados estiverem um pouco antigos, isso prejudica o usuário? Se a resposta for “provavelmente não”, geralmente vale a pena armazenar localmente.
Dados muito lidos e que mudam devagar valem o cache: feeds e listas que as pessoas rolam, conteúdo tipo catálogo (produtos, artigos, templates) e dados de referência como categorias ou países. Configurações e preferências também pertencem aqui, junto com informações básicas de perfil como nome e URL do avatar.
O lado arriscado é tudo que é financeiro ou crítico no tempo. Saldos, status de pagamento, disponibilidade de estoque, horários de consulta, ETAs de entrega e “último visto online” podem causar problemas reais se estiverem desatualizados. Ainda dá para cachear por velocidade, mas trate o cache como um placeholder temporário e force atualização em pontos de decisão (por exemplo, antes de confirmar um pedido).
Estado derivado da UI é outra categoria. Salvar a aba selecionada, filtros, consulta de busca, ordem de ordenação ou posição de rolagem pode tornar a navegação suave. Também pode confundir quando escolhas antigas reaparecem inesperadamente. Uma regra simples funciona bem: mantenha estado de UI em memória enquanto o usuário estiver naquele fluxo, mas reinicie quando ele “recomeçar” intencionalmente (como voltar para a tela inicial).
Evite cachear dados que criem risco de segurança ou privacidade: segredos (senhas, chaves de API), tokens de uso único (códigos OTP, tokens de reset), e dados pessoais sensíveis a menos que precise realmente de acesso offline. Nunca armazene detalhes completos de cartão ou qualquer coisa que aumente o risco de fraude.
Em um app de compras, cachear a lista de produtos é um grande ganho. A tela de checkout, porém, deve sempre atualizar totais e disponibilidade antes da compra.
A maioria dos apps Flutter precisa de um cache local para telas carregarem rápido e não piscarem vazias enquanto a rede acorda. A decisão chave é onde os dados cacheados vivem, porque cada camada tem velocidade, limites de tamanho e comportamento de limpeza diferentes.
Um cache em memória é o mais rápido. Ótimo para dados que você acabou de buscar e vai reutilizar enquanto o app ficar aberto, como o perfil atual, últimos resultados de busca ou um produto recentemente visualizado. O tradeoff é simples: some quando o app é finalizado, então não ajuda em cold starts ou uso offline.
Armazenamento em disco chave-valor serve para itens pequenos que você quer manter entre reinícios. Pense em preferências e blobs simples: flags de feature, “última aba selecionada” e pequenas respostas JSON que raramente mudam. Mantenha intencionalmente pequeno. Ao começar a jogar listas grandes em armazenamento chave-valor, atualizações ficam complicadas e o bloat aparece fácil.
Um banco de dados local é melhor quando seus dados são maiores, estruturados ou precisam de comportamento offline. Também ajuda quando você precisa de consultas (“todas as mensagens não lidas”, “itens no carrinho”, “pedidos do mês passado”) em vez de carregar um grande blob e filtrar em memória.
Para manter o cache previsível, escolha uma loja primária por tipo de dado e evite guardar o mesmo dataset em três lugares.
Uma regra prática:
Também planeje tamanho. Decida o que significa “grande demais”, quanto tempo manter itens e como limpar. Por exemplo: limite resultados de busca em cache às últimas 20 consultas e remova registros mais velhos que 30 dias para que o cache não cresça silenciosamente para sempre.
Regras de atualização devem ser simples o bastante para explicar em uma frase por tela. Aí é que o cache sensato compensa: telas rápidas e app confiável.
A regra mais simples é TTL (time to live). Armazene dados com timestamp e trate como frescos por, digamos, 5 minutos. Depois disso, tornam-se desatualizados. TTL funciona bem para dados "agradáveis de ter" como feed, categorias ou recomendações.
Uma melhoria útil é dividir TTL em soft TTL e hard TTL.
Com soft TTL, você mostra dados em cache imediatamente, atualiza em segundo plano e atualiza a UI se algo mudou. Com hard TTL, você para de mostrar dados antigos quando expiram. Ou bloqueia com um loader ou exibe um estado “offline/tente novamente”. Hard TTL serve quando estar errado é pior que ser lento, como saldos, status de pedido ou permissões.
Se seu backend suportar, prefira “atualizar só quando mudou” usando ETag, updatedAt ou campo de versão. O app pode perguntar “isso mudou?” e pular download do payload completo quando nada for novo.
Um padrão amigável é stale-while-revalidate: mostrar agora, atualizar silenciosamente e redesenhar apenas se o resultado for diferente. Dá velocidade sem piscadas aleatórias.
Frescor por tela frequentemente fica assim:
Escolha regras com base no custo de estar errado, não só no custo de buscar.
Invalidação de cache começa com uma pergunta: que evento torna os dados em cache menos confiáveis que o custo de refetch? Se você escolher um conjunto pequeno de gatilhos e segui-los, o comportamento será previsível e a UI parecerá estável.
Gatilhos que realmente importam em apps reais:
Exemplo: o usuário edita a foto do perfil e volta. Se você depender só de atualização baseada em tempo, a tela anterior pode mostrar a imagem antiga até o próximo fetch. Em vez disso, trate a edição como gatilho: atualize o objeto de perfil cacheado na hora e marque como fresco com novo timestamp.
Mantenha regras de invalidação pequenas e explícitas. Se você não consegue apontar o evento exato que invalida uma entrada de cache, você vai atualizar demais (UI lenta e saltante) ou de menos (telas desatualizadas).
Comece listando suas telas-chave e os dados que cada uma precisa. Não pense em endpoints — pense em objetos visíveis para o usuário: perfil, carrinho, lista de pedidos, item de catálogo, contador de não lidos.
Em seguida, escolha uma fonte de verdade por tipo de dado. No Flutter, geralmente é um repositório que esconde de onde os dados vêm (memória, disco, rede). As telas não devem decidir quando bater na rede. Elas pedem o repositório e reagem ao estado retornado.
Um fluxo prático:
Metadados são o que tornam regras aplicáveis. Se ownerUserId mudar (logout/login), você pode descartar ou ignorar linhas antigas do cache imediatamente em vez de mostrar os dados do usuário anterior por um instante.
Para comportamento da UI, decida desde o começo o que “desatualizado” significa. Uma regra comum: mostrar dados desatualizados instantaneamente para não deixar a tela em branco, iniciar um refresh em background e atualizar quando novos dados chegarem. Se o refresh falhar, mantenha o dado desatualizado visível e mostre um erro pequeno e claro.
Depois, fixe as regras com alguns testes simples:
Essa é a diferença entre “temos cache” e “nosso app se comporta igual sempre”.
Nada quebra confiança mais rápido do que ver um valor na lista, abrir detalhes, editar e voltar para ver o valor antigo. Consistência na navegação vem de fazer cada tela ler da mesma fonte.
Uma regra sólida: buscar uma vez, salvar uma vez, renderizar muitas vezes. Telas não devem chamar o mesmo endpoint independentemente e manter cópias privadas. Coloque dados cacheados em um store compartilhado (sua camada de state management) e deixe lista e detalhe observarem os mesmos dados.
Mantenha um único lugar que seja dono do valor atual e do frescor. Telas podem pedir refresh, mas não devem gerir seus próprios timers, retries e parsing.
Hábitos práticos que evitam “duas realidades”:
Mesmo com boas regras, usuários às vezes verão dados desatualizados (offline, rede lenta, app em background). Torne isso óbvio com sinais pequenos e calmos: um carimbo “Atualizado agora”, um indicador sutil “Atualizando…” ou um badge “Offline”.
Para edições, updates otimistas frequentemente funcionam melhor. Exemplo: o usuário altera o preço de um produto na tela de detalhes. Atualize o store compartilhado na hora para que a lista mostre o novo preço ao voltar. Se o salvamento falhar, reverta e mostre um erro curto.
A maioria das falhas de cache é chata: o cache funciona, mas ninguém consegue explicar quando deve ser usado, quando expira e quem é dono.
A primeira armadilha é cachear sem metadados. Se só armazenar o payload, você não sabe se está velho, qual versão do app gerou ou a qual usuário pertence. Salve ao menos savedAt, um número de versão simples e userId. Esse hábito evita muitos bugs de “por que esta tela está errada?”.
Outro problema comum é múltiplos caches para os mesmos dados sem dono. Uma lista mantém uma lista em memória, um repositório grava no disco e uma tela de detalhes busca de novo e salva em outro lugar. Escolha uma fonte de verdade (geralmente a camada de repositório) e faça todas as telas lerem por ela.
Trocas de conta são um perigo frequente. Se alguém fizer logout ou trocar de conta, limpe tabelas e chaves escopadas ao usuário. Caso contrário você pode mostrar a foto do perfil ou pedidos do usuário anterior por um instante, o que parece uma violação de privacidade.
Correções práticas:
Exemplo: sua lista de produtos carrega instantaneamente do cache e depois atualiza silenciosamente. Se o refresh falhar, continue mostrando o cache mas deixe claro que pode estar desatualizado e ofereça Retry. Não bloqueie a UI quando o cache é suficiente.
Antes do lançamento, transforme o cache de “parece ok” em regras testáveis. Usuários devem ver dados que fazem sentido mesmo após navegar, ficar offline ou entrar com outra conta.
Para cada tela, decida quanto tempo os dados podem ser considerados frescos. Pode ser minutos para dados que mudam rápido (mensagens, saldos) ou horas para dados que mudam devagar (configurações, categorias). Então confirme o que acontece quando não estiver fresco: refresh em background, refresh ao abrir ou pull-to-refresh manual.
Para cada tipo de dado, decida quais eventos devem limpar ou ignorar o cache. Gatilhos comuns: logout, editar item, troca de conta e atualizações do app que mudem a forma dos dados.
Certifique-se de que entradas em cache armazenem um pequeno conjunto de metadados junto ao payload:
Mantenha ownership claro: um repositório por tipo de dado (por exemplo, ProductsRepository), não por widget. Widgets pedem dados, não decidem regras de cache.
Decida e teste também o comportamento offline. Confirme o que cada tela mostra a partir do cache, quais ações ficam desabilitadas e qual texto exibir (“Mostrando dados salvos”, com controle de refresh visível). Refresh manual deve existir em toda tela com cache e ser fácil de encontrar.
Imagine um app de loja simples com três telas: catálogo (lista), detalhes do produto e aba Favoritos. Usuários rolam o catálogo, abrem um produto e tocam no coração para favoritar. O objetivo é parecer rápido mesmo em redes lentas, sem mostrar discrepâncias confusas.
Cache localmente o que ajuda a renderizar instantaneamente: páginas de catálogo (IDs, título, preço, URL da miniatura, flag de favorito), detalhes do produto (descrição, especificações, disponibilidade, lastUpdated), metadados de imagem (URLs, tamanhos, chaves de cache) e os favoritos do usuário (um conjunto de IDs de produto, opcionalmente com timestamps).
Quando o usuário abre o catálogo, mostre resultados em cache imediatamente e depois revalide em background. Se chegarem dados frescos, atualize só o que mudou e mantenha a posição de rolagem.
Para o toggle de favorito, trate como ação que precisa ser consistente. Atualize o conjunto local de favoritos na hora (optimistic update), depois atualize qualquer linha de produto em cache e os detalhes do produto daquele ID. Se a chamada de rede falhar, reverta e mostre uma mensagem curta.
Para manter a navegação consistente, direcione tanto os badges da lista quanto o ícone de coração dos detalhes à mesma fonte de verdade (seu cache local ou store), não a estados separados das telas. O coração da lista atualiza assim que você volta dos detalhes, a tela de detalhes reflete mudanças feitas na lista e a contagem da aba Favoritos bate em todo lugar sem esperar refetch.
Adicione regras simples de refresh: cache do catálogo expira rápido (minutos), detalhes do produto um pouco mais tarde, e favoritos nunca expiram mas sempre se reconciliam após login/logout.
O cache deixa de ser misterioso quando o time aponta para uma página de regras e concorda no que deve acontecer. O objetivo não é perfeição. É comportamento previsível que se mantém entre releases.
Escreva uma pequena tabela por tela e mantenha curta o suficiente para revisar em mudanças: nome da tela e dado principal, local e chave do cache, regra de frescor (TTL, baseada em evento ou manual), gatilhos de invalidação e o que o usuário vê durante a atualização.
Adicione logging leve enquanto você afina. Registre hits e misses de cache e por que um refresh aconteceu (TTL expirou, usuário puxou para atualizar, app retomou, mutação completou). Quando alguém relatar “essa lista está errada”, esses logs tornam o bug solucionável.
Comece com TTLs simples e refine conforme os usuários notarem. Um feed de notícias pode aceitar 5 a 10 minutos de staleness, enquanto uma tela de status de pedido pode precisar de atualização ao retomar e após qualquer ação de checkout.
Se você está construindo um app Flutter rapidamente, ajuda delinear sua camada de dados e regras de cache antes de implementar. Para times usando Koder.ai (koder.ai), o modo de planejamento é um lugar prático para escrever essas regras por tela antes de construir.
Ao ajustar comportamento de refresh, proteja telas estáveis enquanto experimenta. Snapshots e rollback economizam tempo quando uma nova regra introduz flicker, estados vazios ou contagens inconsistentes na navegação.
Comece com uma regra clara por tela: o que pode mostrar imediatamente (cache), quando deve atualizar e o que o usuário vê durante a atualização. Se você não consegue explicar a regra em uma frase, o app acabará parecendo inconsistente.
Trate os dados em cache como tendo um estado de frescor. Se estiver fresco, mostre. Se estiver desatualizado mas utilizável, mostre agora e atualize silenciosamente. Se for necessário atualizar, busque antes de mostrar (ou exiba um estado de carregamento/offline). Isso mantém o comportamento da UI consistente em vez de “às vezes atualiza, às vezes não”.
Armazene coisas lidas com frequência que podem estar um pouco antigas sem prejudicar o usuário: feeds, catálogos, dados de referência e informações básicas de perfil. Tenha cuidado com dados críticos em tempo/financeiros como saldos, disponibilidade de estoque, ETAs e status de pedidos; você pode armazená-los em cache para velocidade, mas force uma atualização antes de uma decisão ou confirmação.
Use memória para reutilização rápida durante a sessão atual (perfil corrente, itens recentemente vistos). Use armazenamento em disco chave-valor para itens pequenos e simples que sobrevivem a reinícios (preferências). Use um banco de dados local quando os dados forem grandes, estruturados, precisarem de consultas ou funcionarem offline (mensagens, pedidos, inventário).
Um TTL simples é um bom padrão: considere dados frescos por um tempo definido e depois atualize. Para muitas telas, uma experiência melhor é “mostrar cache agora, atualizar em segundo plano e redesenhar só se houver diferença”, porque evita telas em branco e reduz flicker.
Invalide em eventos que claramente afetam a confiança no cache: edições do usuário (create/update/delete), login/logout ou troca de conta, retorno ao app se os dados estiverem mais velhos que seu TTL, e atualização explícita pelo usuário. Mantenha esses gatilhos pequenos e explícitos para não acabar atualizando sempre ou nunca quando importa.
Faça as duas telas lerem da mesma fonte de verdade, não de cópias privadas. Quando o usuário editar algo na tela de detalhes, atualize o objeto cacheado compartilhado imediatamente para que a lista mostre o novo valor ao voltar, depois sincronize com o servidor e reverta apenas se o salvamento falhar.
Armazene metadados ao lado do payload, especialmente um timestamp e um identificador do usuário. No logout ou troca de conta, limpe ou isole entradas de cache por usuário imediatamente e cancele requisições em andamento ligadas ao usuário anterior para não renderizar brevemente os dados dele.
Mantenha os dados desatualizados visíveis e mostre um pequeno erro claro que ofereça retry, em vez de deixar a tela em branco. Se a tela não puder mostrar dados antigos com segurança, mude para uma regra de atualização obrigatória e exiba uma mensagem de carregando ou offline em vez de fingir que o valor antigo é confiável.
Coloque regras de cache na sua camada de dados (por exemplo, repositórios) para que todas as telas sigam o mesmo comportamento. Se você estiver prototipando rapidamente em Koder.ai, escreva as regras de frescor e invalidação por tela no modo de planejamento primeiro, e então implemente para que a UI apenas reaja a estados em vez de criar sua própria lógica de atualização.