Blog
Performance
18 de febrero de 202610 min

Egreso en caché de Supabase: -90% de ancho de banda en 2026

Una mañana, abrí mi panel de control de Supabase y casi escupo mi café. El egreso —el ancho de banda saliente de mi base de datos— estaba aumentando mucho más rápido que el número de usuarios de TAMSIV. Cada apertura de la aplicación desencadenaba docenas de solicitudes. Y cada solicitud significa datos en tránsito. Multiplica eso por cientos de usuarios que abren la aplicación varias veces al día, y obtendrás una factura que duele.

Soy un desarrollador individual. No tengo un presupuesto de infraestructura ilimitado. Cada euro cuenta. Así que me arremangué y busqué cómo reducir drásticamente este consumo sin degradar la experiencia del usuario. Spoiler: logré reducir el egreso entre un 80 y un 90%. Y como bonus, la aplicación se volvió más rápida.

Puntos clave a recordar:
- El problema N+1 puede disparar tu ancho de banda sin que te des cuenta
- Una caché de dos niveles (memoria + persistente) elimina la mayoría de las solicitudes inútiles
- Supabase Realtime permite invalidar la caché en tiempo real sin sondeo
- El procesamiento por lotes de solicitudes transforma 20 llamadas en 1 sola
- Optimizar los costos y optimizar la UX a menudo es el mismo trabajo

¿Por qué el egreso de Supabase se dispara en una aplicación móvil?

Antes de hablar de la solución, hay que entender el problema. Supabase, como cualquier servicio en la nube, factura el ancho de banda saliente. Cada vez que tu aplicación hace una solicitud a la base de datos, los datos transitan del servidor al cliente. Eso es el egreso.

En una aplicación web clásica, el usuario carga una página y listo. En una aplicación móvil, es diferente. El usuario navega entre pestañas, actualiza con "pull-to-refresh", abre una tarea, vuelve al feed, abre otra tarea. Cada navegación desencadena solicitudes. Y si tu código no está optimizado, cada solicitud recarga todos los datos, incluso si nada ha cambiado en 30 segundos.

En TAMSIV, tenía dos problemas importantes que multiplicaban el egreso por un factor enorme.

Desarrollador analizando un panel de control que muestra la reducción de los costos de ancho de banda del servidor
El momento en que te das cuenta de que tu panel de costos parece una curva de crecimiento de una startup, excepto que es tu factura.

¿Qué es el problema N+1 y cómo detectarlo?

El primer culpable era el clásico problema N+1. En el feed de TAMSIV, muestro una lista de tareas con sus estadísticas de vistas (quién vio la tarea, cuándo, cuántas veces). Para cada tarea, hacía una llamada individual para recuperar sus ViewStats.

20 tareas mostradas = 20 solicitudes individuales. 50 tareas = 50 solicitudes. Ves el problema. Cada solicitud tiene un costo fijo en términos de latencia de red y datos transferidos (encabezados HTTP, metadatos de respuesta, etc.). Multiplica eso por el número de tareas, y el egreso se dispara.

Lo peor es que este patrón es invisible si no miras tus métricas. La aplicación funciona. Los datos se muestran. Todo parece normal. Pero en segundo plano, estás haciendo 20 veces más solicitudes de las necesarias.

¿Cómo detectarlo? En Supabase, ve al panel de control, sección Reports > API. Mira el número de solicitudes por endpoint. Si ves un endpoint llamado docenas de veces en el mismo segundo, es un N+1. También puedes usar las herramientas de inspección de Supabase para analizar las solicitudes lentas.

¿Cómo funciona el procesamiento por lotes de solicitudes con Supabase?

La solución al N+1 es simple en teoría: en lugar de hacer N solicitudes individuales, haces una sola que recupera todos los datos de una vez. Esto es el procesamiento por lotes.

Creé una función RPC en Supabase, getTaskViewStatsBatch(taskIds), que toma un array de IDs como parámetro y devuelve las estadísticas de todas las tareas en una sola solicitud. Lo mismo para las notas con getMemoViewStatsBatch(memoIds).

-- Antes: 20 llamadas individuales
SELECT * FROM view_stats WHERE task_id = 'xxx';
-- x 20 veces...

-- Después: 1 sola llamada
SELECT * FROM view_stats WHERE task_id = ANY($1);
-- $1 = array de 20 IDs

El resultado es inmediato: 20 solicitudes se convierten en 1. El egreso para esta operación se divide por un factor significativo, no exactamente por 20 porque los datos en sí no han cambiado, pero la sobrecarga de red (encabezados, handshake, etc.) se elimina 19 de cada 20 veces.

Si utilizas las funciones RPC de Supabase, el procesamiento por lotes es trivial de implementar. El operador ANY() de PostgreSQL es tu mejor amigo.

¿Qué es el ContentCacheService y por qué lo necesitas?

El procesamiento por lotes resolvió el problema N+1, pero quedaba el segundo culpable: los datos recargados por completo en cada navegación, incluso si nada había cambiado.

Cuando el usuario abre el feed, las tareas se cargan desde Supabase. Cuando va a la pestaña Agenda y luego vuelve al feed, las tareas se recargan desde Supabase. Los mismos datos, la misma respuesta, el mismo costo de egreso. Para nada.

La solución: una caché inteligente. Diseñé el ContentCacheService, un singleton que gestiona la caché de todos los datos de contenido en TAMSIV. Su principio es simple: nunca rehacer una solicitud si los datos no han cambiado.

Sala de servidores con cables de fibra óptica e indicadores luminosos azules y verdes
Cada byte que sale de estos servidores tiene un costo. La caché reduce drásticamente este tráfico saliente.

¿Cómo implementar una caché de dos niveles en una aplicación React Native?

El ContentCacheService utiliza una caché de dos niveles, cada uno con un rol específico:

  • L1 — Caché en memoria (Map JavaScript): Acceso instantáneo, latencia cero. Los datos están en la RAM. Cuando el usuario navega entre pestañas, el feed se muestra desde L1 sin ninguna solicitud. El problema: los datos desaparecen cuando la aplicación se cierra.
  • L2 — AsyncStorage: Caché persistente en el dispositivo. Más lento que L1 (unos pocos milisegundos de lectura), pero sobrevive a los reinicios de la aplicación. Cuando el usuario vuelve a abrir TAMSIV, los datos de L2 se cargan en L1 y el feed se muestra inmediatamente, incluso antes de que se envíe la primera solicitud a Supabase.

El flujo de lectura es el siguiente:

  1. Buscar en L1 (Map en memoria). Si se encuentra y no ha expirado → devolver inmediatamente.
  2. De lo contrario, buscar en L2 (AsyncStorage). Si se encuentra y no ha expirado → copiar a L1 y devolver.
  3. De lo contrario, solicitar a Supabase → almacenar en L1 y L2 → devolver.

Cada entrada de la caché tiene una marca de tiempo. Un TTL (Time To Live) configurable determina cuándo una entrada se considera caducada. Pero el verdadero cambio de juego es la invalidación en tiempo real con Supabase Realtime.

¿Cómo Supabase Realtime elimina el sondeo?

El problema clásico de la caché es la invalidación. ¿Cómo saber que los datos han cambiado sin rehacer la solicitud? La solución ingenua es el sondeo: verificar cada X segundos si algo ha cambiado. Pero el sondeo es un desperdicio: haces solicitudes para nada el 90% del tiempo.

Supabase Realtime resuelve este problema elegantemente. Es un sistema de suscripción en tiempo real basado en las notificaciones de PostgreSQL. Te suscribes a una tabla, y Supabase te envía un evento cada vez que se crea, modifica o elimina una fila.

En TAMSIV, configuré dos canales:

  • content-cache-tasks: escucha los cambios en privat.tasks
  • content-cache-memos: escucha los cambios en privat.memos

Cuando se detecta un cambio, el ContentCacheService invalida la entrada correspondiente en L1 y L2. En la próxima renderización del componente React, los datos se recargan desde Supabase y se vuelven a almacenar en caché. Los componentes que escuchan los cambios a través de listeners se vuelven a renderizar automáticamente con los nuevos datos.

El resultado: cero sondeo, cero solicitudes inútiles. Los datos siempre están frescos sin desperdiciar ancho de banda. Este es exactamente el patrón que también utilizo en el feed de gamificación y en los grupos colaborativos.

¿Cuál es el impacto real en los costos y el rendimiento?

Los números hablan por sí solos. Después de implementar ContentCacheService + procesamiento por lotes:

  • Egreso reducido entre un 80 y un 90% según los períodos y el número de usuarios activos
  • Número de solicitudes API dividido por 15 a 20 gracias al procesamiento por lotes + caché
  • Tiempo de visualización del feed: casi instantáneo desde la caché L1, frente a 200-500ms antes
  • Recarga después del cierre: ~50ms desde la caché L2, frente a 300-800ms desde Supabase

El bonus inesperado: la UX mejoró considerablemente. El feed se muestra instantáneamente, las transiciones entre pestañas son fluidas, y el "pull-to-refresh" se convirtió en una verdadera actualización (que solo recarga lo que ha cambiado) en lugar de una recarga completa.

Teléfono móvil mostrando una aplicación rápida con contenido en caché junto a un ordenador portátil
La experiencia del usuario se beneficia directamente de la caché: el contenido se muestra instantáneamente.

¿Cómo aplicar esta estrategia a tu propio proyecto?

Si utilizas Supabase (o cualquier backend en la nube) y tu egreso comienza a aumentar, aquí tienes el proceso que recomiendo:

  1. Audita tus solicitudes: Utiliza el panel de control de Supabase o una herramienta de monitoreo para identificar los endpoints más llamados. Busca patrones N+1.
  2. Procesa por lotes las solicitudes repetitivas: Todo lo que hace una solicitud por elemento en una lista debe convertirse en una sola solicitud con un array de IDs.
  3. Implementa una caché de dos niveles: Memoria para la velocidad, almacenamiento persistente para sobrevivir a los reinicios.
  4. Utiliza Realtime para la invalidación: Sin sondeo. Los datos te avisan cuando cambian.
  5. Mide antes y después: Sin métricas, no sabes si tu optimización realmente funcionó.

Este enfoque no es específico de React Native o Supabase. El patrón de caché L1/L2 + invalidación basada en eventos funciona con Firebase, AWS AppSync, o cualquier backend que admita notificaciones en tiempo real.

¿Qué errores evitar al optimizar la caché?

Cometí algunos errores en el camino. Esto es lo que aprendí:

  • No almacenar datos sensibles en AsyncStorage: AsyncStorage no está cifrado por defecto en Android. Para datos sensibles, utiliza un almacenamiento seguro. En mi caso, las tareas y notas no son datos críticos en sí mismos (los tokens de autenticación están en un llavero seguro).
  • Gestionar el tamaño de la caché: Sin límite, la caché L2 puede crecer indefinidamente. Implementé una política de expulsión LRU (Least Recently Used) y un tamaño máximo.
  • Atención a las URLs firmadas: Las imágenes generadas por IA en TAMSIV utilizan URLs firmadas de Supabase que caducan después de 1 hora. La caché debe almacenar la storage_path y regenerar la URL según sea necesario, no almacenar la URL firmada en sí misma.
  • Probar la caché vacía: La experiencia del primer lanzamiento (caché vacía) debe seguir siendo correcta. No asumas que la caché siempre contendrá datos.

¿Cómo interactúa la caché con otros servicios de TAMSIV?

El ContentCacheService no está aislado. Interactúa con varios otros componentes de la arquitectura de TAMSIV:

  • GamificationService: Utiliza la caché para mostrar puntos, insignias y rachas sin solicitudes adicionales. El método refreshFeedImageUrls() regenera las URLs firmadas caducadas.
  • Pipeline vocal: Cuando la IA crea una tarea a través del dictáfono, el evento Realtime invalida la caché y el feed se actualiza automáticamente.
  • Agenda colaborativa: Los eventos compartidos utilizan el mismo patrón de caché con invalidación Realtime.
  • Búsqueda: El SearchService consulta primero la caché L1 antes de lanzar una solicitud a Supabase, lo que hace que la búsqueda sea casi instantánea para los datos ya cargados.

Este sistema de caché se ha convertido en el pilar invisible del rendimiento de TAMSIV. El usuario nunca lo ve directamente, pero lo siente en cada interacción.

Preguntas frecuentes

¿El egreso de Supabase es realmente un problema para proyectos pequeños?

El plan gratuito de Supabase incluye una cuota generosa de egreso. Pero si tu aplicación realiza muchas solicitudes repetitivas (lo cual es común en dispositivos móviles), puedes alcanzar el límite más rápido de lo esperado. Es mejor optimizar temprano que descubrir el problema en pleno crecimiento.

¿La caché no corre el riesgo de mostrar datos obsoletos?

Esa es la ventaja de Supabase Realtime. Tan pronto como un dato cambia en la base de datos, se envía un evento al cliente que invalida la caché. En la práctica, el retraso entre la modificación y la actualización en el lado del cliente es del orden de un segundo. Para una aplicación de productividad como TAMSIV, es imperceptible.

¿Por qué no usar React Query o SWR en su lugar?

React Query y SWR son excelentes bibliotecas para la caché de solicitudes en la web. Pero en un contexto de React Native con AsyncStorage como caché persistente y Supabase Realtime para la invalidación, un servicio a medida ofrece más control. El ContentCacheService gestiona los dos niveles de caché, el TTL, la expulsión y la invalidación Realtime en un solo singleton coherente.

¿Este patrón funciona con otras bases de datos además de Supabase?

El principio es universal. La caché L1/L2 es independiente del backend. Para la invalidación en tiempo real, se necesita un equivalente a las notificaciones de PostgreSQL: Firebase Realtime Database, suscripciones de AWS AppSync, o incluso un simple WebSocket casero. Lo importante es evitar el sondeo.

¿Cuál es el costo de mantenimiento de este sistema de caché?

Una vez implementado, el ContentCacheService es muy estable. Prácticamente no lo he tocado desde su implementación, excepto para añadir nuevas entidades a la caché (eventos de la agenda, por ejemplo). El código es un singleton con una API clara: los otros servicios solo tienen que llamar a get() e invalidate().