Faz alguns meses que mantenho sozinho um app de acompanhamento de séries para iOS e Android. Este post não é para vender o app. É o registro dos problemas que me custaram mais tempo, com o código que ficou no lugar. Talvez poupe alguém de repetir.

Aquisição de Aplicativo de Séries: Experiência e Desafios

TanStack Query, Expo e Firebase

Fizemos uso de TanStack Query 5 para dados remotos, persistidos com @tanstack/query-async-storage-persister + AsyncStorage. O Firebase 12 (Auth + Firestore) foi nosso backend, mais Cloud Functions para o que precisava rodar no servidor (lembretes, webhook de assinatura, convites). Expo 47 + React Native 0.86, com expo-router para navegação. Um código só para iOS, Android (e web, que uso pouco). Expo foi a escolha por ser a única opção para manter dois apps nativos.

Problemas Encenados

  1. O Cache Persistido que Derrubou o Android

Persistir o cache do React Query em disco parecia ganho fácil: as telas abrem já preenchidas, sem "Carregando". Configurei gcTime de 7 dias e persisti tudo. O problema: o persister grava o cache inteiro numa única chave do AsyncStorage, e esse valor atravessa a ponte JS→nativo de uma vez. Cada busca digitada virava uma entrada nova (['search', texto]) guardada por 7 dias, junto com feed, grupos, comentários e likes. Em produção o blob chegou a ~47 MB e o ReadableNativeArray.importArray estourou o heap no Android. Crash fatal, não exceção tratável.

  1. staleTime: Nem "Sempre Buscar", Nem "Confiar no Cache de 7 Dias"

O detalhe de série (['show', id]) é usado por quem percorre a biblioteca inteira: o agendador de notificações e o widget. Testei os dois extremos: staleTime: 0 → em cada abertura e volta do background, uma busca por série, todas ao mesmo tempo. Em aparelho de entrada isso virou tempestade de alocação. O GC segurando o mutator lock aparecia como ANR na thread principal. Cache persistido de 7 dias sem teto → notificação baseada em data antiga do TMDB, ou seja, aviso de episódio que foi adiado.

  1. Notificação de Episódio Novo: Fuso, Next vs Last e Cancelamento que Apagava Tudo

Os avisos são locais (expo-notifications, trigger por data), marcados para as 18h do dia de exibição e só dentro de uma janela de 10 dias. Três bugs reais:

a) Fuso de Origem: A air_date do TMDB é do país de origem. Série americana que estreia à noite lá gerava aviso um dia antes de dar pra assistir aqui. Agora existe um localReleaseDate(air_date, origin_country, networks) que devolve o dia local em que o episódio certamente já saiu.

b) O Episódio que deixa de ser "Próximo": O TMDB move o episódio de next_episode_to_air para last_episode_to_air no instante em que ele vai ao ar na origem. Como o reagendamento cancela os avisos anteriores, o usuário pode receber notificações de episódios que já foram exibidos.

Conclusão

Aprendemos que persistência é opt-in por tipo de query, nunca o cache inteiro. Colocamos teto de tamanho e planejamos a migração de quem já está com o estado ruim. Também aprendemos que staleTime precisa ser maior que o maxAge do persister para garantir que as queries não sejam descartadas antes de serem restauradas. E, por fim, a notificação de episódio novo exigiu atenção especial para lidar com fuso de origem e cancelamento de notificações.

Aqui está o código relevante:

```javascript
// Só o que vale disco: detalhe de série/filme e coleção.
const PERSISTED_QUERY_PREFIXES = ['show', 'movie', 'collection'];

export function shouldPersistQuery(query) {
// Só sucesso: ao passar um shouldDehydrateQuery próprio, o default do TanStack deixa de valer
if (query.state.status!== 'success') return false;
const [prefix] = query.queryKey;
return typeof prefix === 'string' && PERSISTED_QUERY_PREFIXES.includes(prefix);
}

// Teto de ~2 MB: acima disso é melhor ficar sem cache em disco do que crashar
export const cappedStorage = {
getItem: (k) => AsyncStorage.getItem(k),
removeItem: (k) => AsyncStorage.removeItem(k),
setItem: async (k, v) => {
if (v.length > 2 1024 1024) return AsyncStorage.removeItem(k);
return AsyncStorage.setItem(k, v);
},
};

// Notificação de episódio novo: fuso, next vs last e cancelamento que apagava tudo
const localReleaseDate = (air_date, origin_country, networks) => {
// Implementação do fuso de origem
};

export const SHOW_DETAILS_STALE_TIME = 6 60 60; // 6 horas

export const shouldPersistQuery = shouldPersistQuery;
export const cappedStorage = cappedStorage;
export const localReleaseDate = localReleaseDate;
export const SHOW_DETAILS_STALE_TIME = SHOW_DETAILS_STALE_TIME;
```

Esses aprendizados ajudarão a garantir uma experiência mais consistente e eficiente para os usuários do aplicativo de séries.

AplicativoAquisicaoDesafiosExperienciaSeries