Feed React Native: de 3s a 200ms con caché y RPC
Cada aplicación tiene esa pantalla que concentra toda la complejidad. Para TAMSIV, es el Feed. Un flujo único que mezcla todo: actividad reciente, tareas completadas, notas creadas, eventos del calendario, insignias desbloqueadas, progreso de nivel, actividad de grupo. Es la pantalla más ambiciosa de la aplicación, y la que más tiempo me llevó construir.
Lo que te voy a contar aquí es el recorrido completo: desde la primera versión que tardaba 3 segundos en cargar hasta la versión actual que se carga instantáneamente desde la caché. Las elecciones técnicas, las optimizaciones y las lecciones aprendidas en tres semanas de intenso trabajo.
Puntos clave a recordar:
- Una sola RPC de PostgreSQL (get_consolidated_feed) agrega todos los tipos de contenido
- La optimización de los JOINs y la indexación redujeron la carga de 3s a 200ms
- La caché L1 (memoria) + L2 (AsyncStorage) permite una visualización instantánea
- Las URLs firmadas de Supabase caducan después de 60 minutos — la actualización por lotes es crítica
- Cada tipo de elemento tiene su componente dedicado para una FlatList de alto rendimiento
¿Por qué un feed unificado en lugar de pantallas separadas?
La pregunta es legítima. ¿Por qué no tener una pantalla de "tareas recientes", una pantalla de "insignias", una pantalla de "actividad de grupo"? Varias razones:
1. Reducción de la carga cognitiva. El usuario abre una sola vista y ve todo lo que ha sucedido. No es necesario navegar entre 4 pestañas para tener una visión general. Este es el patrón que utilizan todas las aplicaciones sociales —desde Instagram hasta LinkedIn— y los usuarios lo entienden intuitivamente.
2. Descubrimiento pasivo. Un usuario que abre el feed para ver sus tareas recientes también descubrirá que ha desbloqueado una insignia o que un colega ha comentado en un grupo. Esto es cross-engagement: una característica impulsa a otra.
3. Compromiso a través de la gamificación. El sistema de gamificación de TAMSIV (12 niveles, 10 insignias, rachas, desafíos diarios) solo tiene impacto si es visible. Una insignia desbloqueada en una pantalla oculta, nadie la ve. Una insignia en el feed, todos la celebran.
¿Cómo funciona la RPC consolidada?
El corazón técnico del feed es una única función de PostgreSQL: get_consolidated_feed. Esta RPC agrega datos de 5 fuentes diferentes:
- Tareas recientes (esquema
privat.) - Notas recientes (esquema
privat.) - Eventos del calendario (esquema
privat.) - Actividad de gamificación (esquema
gamification.) — insignias, subidas de nivel, rachas - Actividad de grupo (esquema
collaborative.) — tareas compartidas, comentarios, asignaciones
Todo se devuelve en un tipo unificado con un campo item_type para el enrutamiento en el frontend. Cada elemento del feed se identifica por su tipo, y el frontend sabe exactamente qué componente renderizar.
¿Por qué una RPC en lugar de consultas separadas? Porque una sola consulta SQL es siempre más rápida que 5 consultas separadas, incluso con el connection pooling de Supabase. Menos viajes de ida y vuelta por la red, menos latencia, y sobre todo la posibilidad de ordenar y paginar a nivel de la base de datos en lugar de en el cliente.
¿Cómo optimizar una consulta de 3 segundos a 200ms?
Las primeras versiones del feed eran tediosas. 3 segundos de carga. Para una pantalla de inicio, esto es eliminatorio — los usuarios cierran la aplicación antes de que aparezca el contenido.
El problema: JOINs mal optimizados. La RPC inicial realizaba JOINs en tablas sin índice, con subconsultas correlacionadas. EXPLAIN ANALYZE mostraba escaneos secuenciales donde se necesitaban escaneos de índice.
Las optimizaciones aplicadas:
1. Indexación dirigida. Agregué índices compuestos en las columnas utilizadas en las cláusulas WHERE y ORDER BY de la RPC. Un índice en (user_id, created_at DESC) redujo el tiempo de la consulta en un factor de 3 por sí solo.
2. Reescritura de los JOINs. Las subconsultas correlacionadas (SELECT dentro de SELECT) fueron reemplazadas por JOINs laterales. PostgreSQL las optimiza mucho mejor.
3. Limitación de columnas. En lugar de SELECT *, solo recupero las columnas necesarias para la visualización en el feed. Menos datos transferidos = menos tiempo.
4. Paginación del lado del servidor. La RPC acepta los parámetros p_offset y p_limit. Solo se cargan 20 elementos a la vez. La paginación infinita del lado del cliente solicita los siguientes 20 cuando el usuario se acerca al final de la lista.
Resultado: de 3 segundos a 200ms. Un factor de mejora de 15. Este es el tipo de optimización que transforma una aplicación "utilizable" en una aplicación "agradable".
¿Cómo renderizar cada tipo de elemento de manera eficiente?
El feed mezcla elementos muy diferentes: una tarea tiene un título, una prioridad, una fecha límite. Una insignia tiene un icono, un nombre, una descripción. Un evento tiene una hora, un lugar, participantes. Cada tipo requiere un componente dedicado.
Los componentes del feed:
FeedTaskItem: tarea con prioridad, fecha, asignaciónFeedMemoItem: nota con vista previa del contenido e imagen de portadaFeedGamificationItem: insignia, subida de nivel, hito de rachaFeedGroupItem: actividad colaborativa (nuevo miembro, tarea asignada, comentario)FeedCalendarItem: evento próximo
Todo dentro de un FlatList de react-native-gesture-handler (obligatorio, no el de react-native — ver los gotchas del gesture-handler) con getItemLayout para el cálculo de altura y keyExtractor basado en el par (item_type, id).
El patrón getItemLayout es crucial para el rendimiento: permite a la FlatList calcular la posición de cada elemento sin renderizarlo. Sin esto, el desplazamiento se entrecorta cuando la lista contiene cientos de elementos.
¿Cómo resolver el problema de las imágenes caducadas?
Esta es una de las trampas más viciosas de Supabase Storage. Las URLs firmadas caducan después de 60 minutos. Si un elemento del feed contiene una imagen (archivo adjunto de una tarea, portada de una nota), la URL almacenada en el feed caduca y la imagen ya no se muestra.
La solución en dos partes:
1. Almacenar el storage_path, no la URL. La RPC get_consolidated_feed devuelve un campo firstImageStoragePath además de firstImageUri. La ruta es permanente, la URL es temporal.
2. Actualización por lotes de las URLs. GamificationService.refreshFeedImageUrls() llama a StorageService.refreshAttachmentUrlsBatch() para regenerar todas las URLs caducadas en una sola llamada por lotes. No hay llamadas individuales por imagen, sino una sola llamada para todas las imágenes del feed.
Este patrón se detalla en el artículo sobre la reducción del egreso de Supabase. Las URLs firmadas son un patrón potente para la seguridad, pero crean una complejidad de gestión que muchos desarrolladores subestiman.
¿Cómo funciona la caché multinivel?
La caché del feed es el secreto de la visualización instantánea. Funciona en tres niveles:
Caché L1 — Memoria (Map). Los datos del feed se guardan en un Map en memoria a través del ContentCacheService. Es el más rápido: acceso en O(1), sin deserialización. El feed se carga desde L1 en menos de 10ms.
Caché L2 — AsyncStorage. Si L1 está vacío (primer inicio, reinicio de la aplicación), los datos se recuperan de AsyncStorage. Más lento que la memoria (~50ms) pero más rápido que una llamada de red.
Fuente de verdad — Supabase. En segundo plano, los datos frescos se recuperan de Supabase y la caché se actualiza. El usuario ve los datos de la caché inmediatamente, luego la vista se actualiza silenciosamente si hay nuevos datos disponibles.
El patrón "cache-first + background refresh" es utilizado por la mayoría de las aplicaciones de alto rendimiento. Es lo que SWR hace en la web (stale-while-revalidate). En React Native, lo he implementado manualmente con el ContentCacheService.
¿Supabase Realtime añade valor al feed?
Sí, y es lo que hace que el feed esté "vivo". Supabase Realtime está configurado en las tablas privat.tasks y privat.memos. Se abren dos canales: content-cache-tasks y content-cache-memos.
Cuando se crea, completa o modifica una tarea (por el usuario o por un miembro de su grupo), se recibe el evento Realtime y la caché L1 se invalida. La próxima vez que se muestre el feed, recuperará los datos frescos.
El resultado: cuando un colega completa una tarea en un grupo colaborativo, la actividad aparece en tu feed en cuestión de segundos. Sin actualización manual, sin pull-to-refresh, sucede automáticamente.
¿Cuál es el rendimiento en dispositivos de gama baja?
Un feed complejo con imágenes, insignias y animaciones puede ser problemático en dispositivos modestos. Aquí están las optimizaciones específicas:
- Reciclaje de componentes: la FlatList solo renderiza los elementos visibles. Los elementos fuera de la pantalla se reciclan, no se eliminan y recrean.
- Imágenes de carga diferida (lazy-loaded): las imágenes solo se cargan cuando entran en el viewport + un margen de 300px.
- Animaciones reducidas: en dispositivos detectados como "lentos" (a través de
InteractionManager), las animaciones se simplifican. - Estadísticas de vista por lotes: las estadísticas de vista (cuántas veces se ha visto un elemento) se envían en lotes, no individualmente. Esto pasó de N+1 solicitudes a solo 1 gracias a las RPC
getTaskViewStatsBatchygetMemoViewStatsBatch.
Estas optimizaciones permiten que el feed funcione correctamente en dispositivos con 2 GB de RAM, lo que cubre la mayoría del mercado Android.
¿Cómo se integra la gamificación en el feed?
El sistema de gamificación de TAMSIV incluye 12 niveles, 10 insignias, rachas (hasta 365 días) y desafíos diarios. Cada evento de gamificación (insignia desbloqueada, subida de nivel, hito de racha) aparece en el feed como un elemento dedicado.
El FeedGamificationItem es visualmente distinto de los otros elementos: un color de acento diferente, una animación sutil y un mensaje de felicitación. Es un momento de celebración en el flujo, rompe el ritmo monótono de las tareas y notas e inyecta emoción.
La integración se realiza a través del GamificationService (singleton) que se llama desde useTaskDetail (al completar una tarea) y memoCreation.ts (al crear una nota). El servicio verifica las condiciones de insignias y niveles y crea los elementos del feed correspondientes a través de las RPC dedicadas. El sistema de notificaciones también se activa para los logros importantes.
Lo que aprendí construyendo el feed
Esta pantalla me llevó tres semanas. También es de la que estoy más orgulloso. No porque sea visualmente espectacular —es un feed bastante clásico— sino porque funciona bien. Es rápido, fiable y agradable de usar.
La lección principal: el rendimiento es una característica. Un feed que tarda 3 segundos en cargar, nadie lo usa, sin importar la cantidad de características que contenga. Un feed que se carga instantáneamente, los usuarios vuelven a él de forma natural.
Es la misma filosofía que he aplicado a toda la aplicación: las microinteracciones fluidas, el onboarding sin fricciones, la búsqueda instantánea. La velocidad y la fluidez son las mejores características de retención que un desarrollador individual puede implementar.
Preguntas frecuentes
¿Cuántos elementos puede contener el feed?
Técnicamente, el feed es infinito gracias a la paginación del lado del servidor (20 elementos por página). En la práctica, los usuarios activos acumulan unos cientos de elementos al mes. La FlatList con reciclaje gestiona miles de elementos sin problemas de memoria.
¿El feed consume muchos datos móviles?
No. La caché L1/L2 evita las solicitudes repetidas. Las imágenes se comprimen y se cargan de forma diferida (lazy-loaded). Una actualización completa del feed consume aproximadamente 50 KB de datos (sin incluir imágenes). Las imágenes representan la mayor parte del tráfico, pero solo se cargan una vez y se almacenan en caché.
¿Se puede filtrar el feed por tipo de contenido?
Todavía no en la versión actual. El feed muestra todo en orden cronológico. Es una elección deliberada: el feed es un lugar de descubrimiento, no de búsqueda. Para encontrar una tarea específica, existe la búsqueda dedicada.
¿El Realtime funciona en segundo plano?
No. Los canales de Supabase Realtime se cierran cuando la aplicación pasa a segundo plano (para ahorrar batería). Cuando la aplicación vuelve a primer plano, los canales se reabren y la caché se actualiza. El tiempo de reconexión es de 1 a 2 segundos.
¿Cómo gestiona el feed los contenidos eliminados?
Los elementos eliminados se retiran de la caché L1 y L2 inmediatamente a través de los eventos Realtime (tipo de evento DELETE). Si un elemento eliminado todavía es visible en la FlatList, desaparece con una animación de desvanecimiento.