Estrategias de caché en Flutter: qué almacenar, cuándo invalidar y cómo mantener las pantallas consistentes entre navegación.

Cachear en una app móvil significa mantener una copia de los datos cerca (en memoria o en el dispositivo) para que la siguiente pantalla pueda renderizar al instante en lugar de esperar la red. Esos datos pueden ser una lista de elementos, el perfil de un usuario o resultados de búsqueda.
Lo difícil es que los datos cacheados suelen estar un poco desactualizados. Los usuarios lo notan rápido: un precio que no se actualiza, un contador de notificaciones que parece atascado, o una pantalla de detalles que muestra información antigua justo después de cambiarla. Lo que hace que esto sea doloroso de depurar es el tiempo. El mismo endpoint puede verse bien tras un pull-to-refresh, pero mal después de navegar hacia atrás, reanudar la app o cambiar de cuenta.
Hay un verdadero tradeoff. Si siempre pides datos frescos, las pantallas se sienten lentas y saltan, y malgastas batería y datos. Si cacheas agresivamente, la app se siente rápida pero la gente deja de confiar en lo que ve.
Un objetivo simple ayuda: hacer que la frescura sea predecible. Decide qué puede mostrar cada pantalla (fresco, ligeramente obsoleto o sin conexión), cuánto puede vivir un dato antes de refrescarse y qué eventos deben invalidarlo.
Imagina un flujo común: un usuario abre un pedido y vuelve a la lista de pedidos. Si la lista viene de la caché, puede seguir mostrando el estado antiguo. Si refrescas siempre, la lista puede parpadear y sentirse lenta. Reglas claras como “muestra cacheado al instante, refresca en segundo plano y actualiza ambas pantallas cuando llegue la respuesta” hacen que la experiencia sea consistente al navegar.
Una caché no es solo “datos guardados”. Es una copia guardada más una regla sobre cuándo esa copia sigue siendo válida. Si guardas el payload pero omites la regla, acabas con dos versiones de la realidad: una pantalla muestra información nueva y otra la de ayer.
Un modelo práctico es poner cada ítem cacheado en uno de tres estados:
Este encuadre mantiene tu UI predecible porque puede responder igual cada vez que vea un estado dado.
Las reglas de frescura deben basarse en señales que puedas explicar a un compañero. Opciones comunes son caducidad por tiempo (por ejemplo, 5 minutos), cambio de versión (schema o versión de la app), acción del usuario (pull to refresh, enviar, eliminar), o una pista del servidor (ETag, timestamp last-updated o una respuesta explícita de “invalidar caché”).
Ejemplo: una pantalla de perfil carga datos cacheados del usuario de inmediato. Si están obsoletos-pero-usable, muestra el nombre y avatar cacheados y luego refresca en silencio. Si el usuario acaba de editar su perfil, eso es un momento de "debe refrescarse". La app debe actualizar la caché inmediatamente para que todas las pantallas sigan consistentes.
Decide quién posee estas reglas. En la mayoría de apps, el mejor valor por defecto es: la capa de datos posee la frescura y la invalidación; la UI solo reacciona (mostrar cacheado, mostrar carga, mostrar error); y el backend ofrece pistas cuando puede. Esto evita que cada pantalla invente sus propias reglas.
Una buena estrategia de caché empieza con una pregunta: si estos datos están un poco viejos, ¿dañará eso al usuario? Si la respuesta es “probablemente no”, suele ser buen candidato para cache local.
Los datos que se leen mucho y cambian lentamente suelen valer la pena cachearlos: feeds y listas que la gente recorre, contenido tipo catálogo (productos, artículos, plantillas) y datos de referencia como categorías o países. Configuraciones y preferencias también van aquí, junto con información básica de perfil como nombre y URL del avatar.
Lo arriesgado son los datos relacionados con dinero o críticos en tiempo. Saldos, estado de pagos, disponibilidad de stock, franjas de citas, ETAs de entrega y “última conexión” pueden causar problemas reales si están obsoletos. Aun así puedes cachearlos por velocidad, pero trata la caché como un placeholder temporal y fuerza una actualización en puntos de decisión (por ejemplo, justo antes de confirmar un pedido).
El estado derivado de la UI es otra categoría. Guardar la pestaña seleccionada, filtros, consulta de búsqueda, orden de clasificación o posición de scroll puede hacer que la navegación sea fluida. También puede confundir cuando reaparecen elecciones antiguas inesperadamente. Una regla simple funciona bien: conserva el estado de UI en memoria mientras el usuario permanece en ese flujo, pero restablécelo cuando intencionalmente “empiece de nuevo” (por ejemplo, volver a la pantalla principal).
Evita cachear datos que creen riesgos de seguridad o privacidad: secretos (contraseñas, API keys), tokens de un solo uso (códigos OTP, tokens de restablecimiento), y datos personales sensibles a menos que realmente necesites acceso offline. Nunca cachees detalles completos de tarjeta o cualquier cosa que aumente el riesgo de fraude.
En una app de compras, cachear la lista de productos es una gran ventaja. La pantalla de checkout, en cambio, siempre debe refrescar totales y disponibilidad justo antes de la compra.
La mayoría de apps Flutter terminan necesitando una caché local para que las pantallas carguen rápido y no aparezcan vacías mientras la red despierta. La decisión clave es dónde viven los datos cacheados, porque cada capa tiene distinta velocidad, límite de tamaño y comportamiento de limpieza.
Una caché en memoria es la más rápida. Es ideal para datos que acabas de obtener y vas a reutilizar mientras la app esté abierta, como el perfil actual, los últimos resultados de búsqueda o un producto que el usuario acaba de ver. El tradeoff es sencillo: desaparece cuando la app es matada, por lo que no ayuda en arranques en frío o uso offline.
El almacenamiento en disco tipo key-value sirve para ítems pequeños que quieres conservar entre reinicios. Piensa en preferencias y blobs simples: flags de features, “última pestaña seleccionada” y respuestas JSON pequeñas que cambian raramente. Mantenlo intencionalmente pequeño. Si empiezas a meter listas grandes en key-value, las actualizaciones se vuelven engorrosas y es fácil inflar el tamaño.
Una base de datos local es mejor cuando tus datos son grandes, estructurados o necesitan comportamiento offline. También ayuda cuando necesitas consultas (“todos los mensajes sin leer”, “items en el carrito”, “pedidos del mes pasado”) en lugar de cargar un blob gigante y filtrar en memoria.
Para mantener la caché predecible, elige una tienda primaria por tipo de dato y evita mantener el mismo dataset en tres sitios.
Una regla rápida:
También planifica el tamaño. Decide qué significa “demasiado grande”, cuánto tiempo conservas items y cómo limpias. Por ejemplo: limita resultados de búsqueda cacheados a las últimas 20 consultas y elimina regularmente registros con más de 30 días para que la caché no crezca sin control.
Las reglas de refresco deben ser lo bastante simples como para explicarlas en una oración por pantalla. Ahí es donde la caché sensata paga dividendos: los usuarios obtienen pantallas rápidas y la app sigue confiable.
La regla más simple es TTL (time to live). Guarda datos con una marca temporal y trátalos como frescos, por ejemplo, 5 minutos. Después de eso, pasan a estar obsoletos. TTL funciona bien para datos “agradables de tener” como un feed, categorías o recomendaciones.
Un refinamiento útil es dividir el TTL en soft TTL y hard TTL.
Con un soft TTL, muestras datos cacheados inmediatamente, luego refrescas en segundo plano y actualizas la UI si cambió. Con un hard TTL, dejas de mostrar datos viejos tras expirar: bloqueas con un loader o muestras un estado “offline/volver a intentar”. Hard TTL encaja donde estar equivocado es peor que ser lento, como saldos, estado de pedidos o permisos.
Si tu backend lo soporta, prefiere “refrescar solo si cambió” usando un ETag, updatedAt o un campo de versión. Tu app puede preguntar “¿ha cambiado esto?” y evitar descargar el payload completo cuando no hay novedades.
Un valor por defecto amigable para muchas pantallas es: mostrar ahora, refrescar en silencio y redibujar solo si el resultado difiere. Da velocidad sin parpadeos aleatorios.
Frecuentemente las reglas por pantalla quedan así:
Elige reglas según el costo de estar equivocado, no solo el costo de pedir datos.
La invalidación empieza con una pregunta: ¿qué evento hace que los datos cacheados sean menos confiables que el coste de volver a obtenerlos? Si eliges un conjunto pequeño de disparadores y te ciñes a ellos, el comportamiento será predecible y la UI se sentirá estable.
Disparadores que importan en apps reales:
Ejemplo: un usuario edita su foto de perfil y vuelve. Si dependes solo de refresco por tiempo, la pantalla anterior puede mostrar la imagen antigua hasta el siguiente fetch. En su lugar, trata la edición como disparador: actualiza el objeto de perfil cacheado de inmediato y márcalo como fresco con una nueva marca temporal.
Mantén las reglas de invalidación pequeñas y explícitas. Si no puedes señalar el evento exacto que invalida una entrada de caché, refrescarás demasiado (UI lenta y saltos) o no lo suficiente (pantallas obsoletas).
Empieza listando tus pantallas clave y los datos que cada una necesita. No pienses en endpoints: piensa en objetos visibles por el usuario: perfil, carrito, lista de pedidos, ítem de catálogo, contador de no leídos.
Luego, elige una fuente de verdad por tipo de dato. En Flutter, esto suele ser un repository que oculta de dónde vienen los datos (memoria, disco, red). Las pantallas no deberían decidir cuándo tocar la red. Deben pedir datos al repository y reaccionar al estado retornado.
Un flujo práctico:
Los metadatos son lo que hacen las reglas aplicables. Si ownerUserId cambia (logout/login), puedes descartar o ignorar filas cacheadas antiguas de inmediato en lugar de mostrar los datos del usuario previo por medio segundo.
Para el comportamiento de UI, decide de antemano qué significa “obsoleto”. Una regla común: mostrar datos obsoletos instantáneamente para que la pantalla no esté en blanco, iniciar un refresco en segundo plano y actualizar cuando lleguen nuevos datos. Si el refresco falla, mantén los datos obsoletos visibles y muestra un error pequeño y claro.
Después fija las reglas con unas pocas pruebas aburridas:
Esa es la diferencia entre “tenemos caché” y “nuestra app se comporta igual cada vez”.
Nada destruye la confianza más rápido que ver un valor en la lista, entrar en detalle, editarlo y volver para ver el valor antiguo. La consistencia al navegar viene de hacer que cada pantalla lea de la misma fuente.
Una regla sólida es: pedir una vez, guardar una vez, renderizar muchas veces. Las pantallas no deberían llamar al mismo endpoint de forma independiente y mantener copias privadas. Pon los datos cacheados en una tienda compartida (tu capa de estado) y deja que tanto la lista como la pantalla de detalle observen los mismos datos.
Mantén un único lugar que posea el valor actual y la frescura. Las pantallas pueden solicitar un refresco, pero no deberían gestionar sus propios timers, reintentos y parseos.
Hábitos prácticos para evitar “dos versiones de la realidad”:
Incluso con buenas reglas, los usuarios a veces verán datos obsoletos (sin conexión, red lenta, app en background). Hazlo obvio con señales pequeñas y calmadas: una marca “Actualizado hace un momento”, un indicador sutil “Actualizando…”, o un badge “Offline”.
Para las ediciones, las actualizaciones optimistas suelen sentirse mejor. Ejemplo: un usuario cambia el precio de un producto en detalle. Actualiza el store compartido de inmediato para que la lista muestre el nuevo precio al volver. Si el guardado falla, revierte al valor anterior y muestra un error breve.
La mayoría de fallos con caché son aburridos: la caché funciona, pero nadie puede explicar cuándo usarla, cuándo expira y quién la posee.
La primera trampa es cachear sin metadatos. Si guardas solo el payload, no puedes saber si está viejo, qué versión de la app lo produjo o a qué usuario pertenece. Guarda al menos savedAt, un número de versión simple y un userId (o tenant key). Ese hábito evita muchos bugs de “¿por qué está mal esta pantalla?”.
Otro problema común es múltiples cachés para los mismos datos sin dueño. Una pantalla de lista mantiene una lista en memoria, un repository escribe al disco y una pantalla de detalles vuelve a pedir y guarda en otro sitio. Elige una fuente de verdad (a menudo la capa de repository) y haz que todas las pantallas lean por ahí.
Los cambios de cuenta son una trampa frecuente. Si alguien cierra sesión o cambia de cuenta, limpia tablas y claves por usuario. De lo contrario podrías mostrar la foto de perfil o pedidos del usuario anterior por un instante, lo que parece una brecha de privacidad.
Fijar soluciones prácticas:
Ejemplo: tu lista de productos carga instantáneamente desde caché y luego refresca en silencio. Si el refresco falla, sigue mostrando datos cacheados pero aclara que pueden estar desactualizados y ofrece Reintentar. No bloquees la UI en el refresco cuando los datos cacheados serían suficientes.
Antes de la release, convierte la caché de “parece estar bien” a reglas que puedas probar. Los usuarios deben ver datos que tengan sentido incluso tras navegar, estar offline o iniciar sesión con otra cuenta.
Para cada pantalla, decide cuánto tiempo los datos pueden considerarse frescos. Pueden ser minutos para datos que cambian rápido (mensajes, saldos) u horas para datos lentos (configuración, categorías de producto). Luego confirma qué pasa cuando no están frescos: refresco en background, refresco al abrir o pull-to-refresh manual.
Para cada tipo de dato, decide qué eventos deben borrar o ignorar la caché. Disparadores comunes: logout, editar el ítem, cambiar cuentas y actualizaciones de app que cambian la forma de los datos.
Asegúrate de que las entradas cacheadas almacenen un pequeño conjunto de metadatos junto al payload:
Mantén la propiedad clara: usa un repository por tipo de dato (por ejemplo, ProductsRepository), no por widget. Los widgets deben pedir datos, no decidir reglas de caché.
Decide y prueba el comportamiento offline. Confirma qué pantallas muestran datos de caché, qué acciones están deshabilitadas y qué texto muestras (“Mostrando datos guardados”, más un control de refresco visible). El reintento manual debe existir en cada pantalla basada en caché y ser fácil de encontrar.
Imagina una tienda simple con tres pantallas: catálogo de productos (lista), detalle de producto y pestaña Favoritos. Los usuarios recorren el catálogo, abren un producto y tocan un corazón para marcarlo favorito. La meta es sentirse rápida, aun con redes lentas, sin mostrar desajustes confusos.
Cachea localmente lo que ayuda a renderizar al instante: páginas del catálogo (IDs, título, precio, URL de miniatura, flag de favorito), detalles del producto (descripción, especificaciones, disponibilidad, lastUpdated), metadatos de imagen (URLs, tamaños, cache keys) y los favoritos del usuario (conjunto de IDs de producto, opcionalmente con timestamps).
Cuando el usuario abre el catálogo, muestra resultados cacheados de inmediato y luego revalida en segundo plano. Si llega datos frescos, actualiza solo lo que cambió y mantiene la posición de scroll estable.
Para el toggle de favorito, trátalo como una acción que “debe ser consistente”. Actualiza el conjunto local de favoritos de inmediato (actualización optimista), luego actualiza las filas cacheadas de producto y los detalles cacheados para ese ID. Si la llamada de red falla, revierte y muestra un pequeño mensaje.
Para mantener la consistencia al navegar, impulsa tanto los badges de la lista como el icono de corazón del detalle desde la misma fuente de verdad (tu caché local o store), no desde estados de pantalla separados. El corazón de la lista se actualiza al volver desde detalle, la pantalla de detalle refleja cambios hechos desde la lista y la cuenta en Favoritos coincide en todas partes sin esperar un refetch.
Añade reglas de refresco simples: la caché de catálogo expira rápido (minutos), detalles de producto algo más tarde, y favoritos nunca expiran pero siempre se reconcilian tras login/logout.
La caché deja de ser misteriosa cuando tu equipo puede señalar una página de reglas y ponerse de acuerdo sobre qué debe pasar. La meta no es la perfección: es un comportamiento predecible que se mantiene igual entre versiones.
Escribe una pequeña tabla por pantalla y mantenla lo bastante corta para revisarla cuando cambies algo: nombre de pantalla y dato principal, ubicación y clave de la caché, regla de frescura (TTL, basada en evento o manual), disparadores de invalidación y qué ve el usuario mientras se refresca.
Añade logging ligero mientras afinas. Registra hits y misses de la caché y por qué ocurrió un refresco (TTL expirado, usuario hizo pull-to-refresh, app reanudada, mutación completada). Cuando alguien reporte “esta lista se siente rara”, esos logs hacen el bug solucionable.
Empieza con TTLs simples y luego afina según lo que noten los usuarios. Un feed de noticias puede tolerar 5 a 10 minutos de obsolescencia, mientras que una pantalla de estado de pedido puede necesitar refrescar al reanudar y tras cualquier acción de checkout.
Si estás construyendo una app Flutter rápido, ayuda bosquejar tu capa de datos y reglas de caché antes de implementar. Para equipos que usan Koder.ai (koder.ai), el modo de planificación es un lugar práctico para escribir primero esas reglas por pantalla y luego construir para que coincidan.
Cuando afinas el comportamiento de refresco, protege pantallas estables mientras experimentas. Snapshots y rollback pueden ahorrar tiempo cuando una regla nueva introduce parpadeos, estados vacíos o recuentos inconsistentes al navegar.
Empieza con una regla clara por pantalla: qué puede mostrar inmediatamente (cacheado), cuándo debe refrescarse y qué ve el usuario durante la actualización. Si no puedes explicar la regla en una sola frase, la app acabará sintiéndose inconsistente.
Trata los datos cacheados como si tuvieran un estado de frescura. Si es fresco, muéstralo. Si está obsoleto pero usable, muéstralo ahora y actualízalo en silencio. Si debe refrescarse, pide al servidor antes de mostrarlo (o muestra un estado de carga/desconectado). Esto mantiene el comportamiento de la UI consistente en lugar de “a veces se actualiza y a veces no”.
Cachea lo que se lee mucho y puede estar un poco anticuado sin perjudicar al usuario: feeds, catálogos, datos de referencia y la información básica de perfil. Ten cuidado con datos de dinero o críticos en tiempo, como saldos, disponibilidad de stock, ETAs y estados de pedido; puedes cachearlos para velocidad, pero fuerza una actualización justo antes de una decisión o confirmación.
Usa memoria para reutilizar durante la sesión actual (perfil actual, elementos vistos recientemente). Usa almacenamiento en disco key-value para objetos pequeños que deben sobrevivir reinicios (preferencias). Usa una base de datos local cuando los datos sean grandes, estructurados, necesiten consultas o deban funcionar sin conexión (mensajes, pedidos, inventario).
Una TTL simple es un buen punto de partida: considera los datos frescos durante un tiempo fijo y luego actualízalos. Para muchas pantallas la experiencia mejora con “muestra cacheado ahora, refresca en segundo plano y actualiza si cambió”, porque evita pantallas en blanco y reduce el parpadeo.
Invalida en eventos que claramente reducen la confianza en la caché: ediciones del usuario (crear/actualizar/eliminar), login/logout o cambio de cuenta, reanudar la app si los datos son más antiguos que tu TTL, y actualizaciones explícitas por parte del usuario. Mantén los disparadores pequeños y explícitos para no acabar refrescando constantemente o nunca cuando importa.
Haz que ambas pantallas lean de la misma fuente de la verdad, no de copias privadas. Cuando el usuario edita algo en la pantalla de detalle, actualiza el objeto cacheado compartido inmediatamente para que la lista muestre el nuevo valor al volver, y luego sincroniza con el servidor y revierte solo si la guardada falla.
Siempre guarda metadatos al lado del payload, especialmente una marca temporal y un identificador de usuario. En logout o cambio de cuenta, borra o aísla las entradas de caché por usuario de inmediato y cancela las peticiones en vuelo vinculadas al usuario anterior para no renderizar brevemente los datos del usuario previo.
Por defecto, mantén visibles los datos obsoletos y muestra un error pequeño y claro que ofrezca reintentar, en lugar de dejar la pantalla en blanco. Si la pantalla no puede mostrar datos antiguos con seguridad, cámbiala a una regla de "debe refrescarse" y muestra un mensaje de carga o de desconexión en lugar de fingir que el valor antiguo es fiable.
Pon la lógica de caché en la capa de datos (por ejemplo, repositorios) para que cada pantalla siga el mismo comportamiento. Si necesitas moverte rápido con Koder.ai, escribe primero las reglas de frescura e invalidación por pantalla en el modo de planificación y luego implementa para que la UI solo reaccione a estados en lugar de inventar su propia lógica de refresco.