effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

Por qué un solo listener de Firestore puede disparar la factura de tu app Flutter: la trampa de los $0,60 por millón de lecturas

Por qué un solo listener de Firestore puede disparar la factura de tu app Flutter: la trampa de los $0,60 por millón de lecturas

Si alguna vez has visto la gráfica de uso de la consola de Firebase dar un salto en escalera de un día para otro, la causa casi nunca está en la lógica de negocio, sino en el ciclo de vida de los widgets. No añadiste ninguna función nueva, no tuviste un pico de usuarios, y aun así el número de lecturas de Firestore se multiplica por diez de la noche a la mañana: este tipo de incidente es especialmente frecuente en apps Flutter. La razón es sencilla. StreamBuilder y .snapshots() son tan fáciles de usar que esconden por completo el ciclo de vida del listener, y cada vez que el widget se reconstruye, el listener se reconecta en silencio y vuelve a leer desde cero documentos que ya se habían leído antes.

Este artículo no es una advertencia genérica de que “Firestore sale caro”, sino una demostración con cifras reales, calculadas al céntimo, de cuánto añade a la factura mensual uno de estos antipatrones habituales. Cubrimos la tabla de precios, los tres antipatrones que se repiten una y otra vez en Flutter, la curva de coste a escalas de 5.000 y 100.000 usuarios activos diarios, cómo calcular el punto exacto en el que se agota la cuota diaria gratuita, y patrones de mitigación junto con arquitecturas alternativas.

Resumen ejecutivo

Empecemos por fijar el precio exacto de Firestore Blaze

Antes de cualquier impresión aproximada, empecemos con las cifras exactas. La siguiente tabla recoge los valores tal cual aparecen en la página oficial de precios de Firebase/Google Cloud (para las multi-regiones por defecto, como nam5 y eur3).

Concepto Cuota diaria gratuita Precio al superarla
Lecturas de documentos (Document Reads) 50.000/día $0,06 / 100.000 ($0,60 por millón)
Escrituras de documentos (Document Writes) 20.000/día $0,18 / 100.000
Eliminaciones de documentos (Document Deletes) 20.000/día $0,02 / 100.000
Almacenamiento (Stored Data) 1 GiB Facturación mensual por GiB (varía según la región)
Salida de red (Egress) 10 GiB/mes Varía según región y destino

Aquí hay que detenerse en tres reglas concretas que quien trabaja con esto a diario suele pasar por alto.

Y hay una regla más que conecta directamente con el núcleo de este artículo. La documentación oficial describe así el modelo de facturación de los listeners en tiempo real:

“Cuando escuchas el resultado de una consulta, se factura una lectura cada vez que se añade o se actualiza un documento en el conjunto de resultados. A partir de ahí, un listener activo solo factura los documentos en los que se detecta un cambio. Sin embargo, si la persistencia offline está desactivada y el listener se desconecta y se vuelve a conectar, se factura como si se ejecutara una consulta completamente nueva, cobrando de nuevo la lectura de documentos y entradas de índice.

En otras palabras, un listener de .snapshots() es, en efecto, una estructura eficiente: “una vez conectado, a partir de ahí solo se cobran los cambios, de forma barata”. El problema es que esa eficiencia solo se sostiene si el listener permanece conectado de forma continua, y el árbol de widgets de Flutter no garantiza por defecto que un listener se mantenga conectado.

Los 3 antipatrones que se repiten en las apps Flutter

Los patrones que se observan una y otra vez tanto en la comunidad de Firebase como en incidentes reales de producción se agrupan en tres. Los tres tienen algo en común: el código “funciona”, así que rara vez se detectan en una revisión de código, y solo salen a la luz cuando la factura se dispara.

Antipatrón 1: consultar un documento distinto por cada elemento de una lista (N+1)

Es el más habitual en pantallas donde cada elemento de una lista hace referencia a un documento de otra colección, como listas de chats, feeds o historiales de pedidos.

// Antipatrón: una llamada get() por cada elemento
ListView.builder(
  itemCount: chats.length,
  itemBuilder: (context, index) {
    final chat = chats[index];
    return FutureBuilder<DocumentSnapshot>(
      future: FirebaseFirestore.instance
          .collection('users')
          .doc(chat.otherUserId)
          .get(), // con 50 elementos en la lista, son 50 lecturas extra, repetidas en cada scroll o rebuild
      builder: (context, userSnap) {
        final name = userSnap.data?.get('displayName') ?? '...';
        return ListTile(title: Text(name));
      },
    );
  },
)

La consulta de la lista en sí supone 1 lectura (o el tamaño de página, si hay paginación), pero al consultar el perfil de la otra persona en cada elemento se producen N lecturas adicionales, cada vez que se pinta la pantalla. Si esta consulta se repite sin caché cada vez que el widget se reconstruye al hacer scroll o cada vez que se revisita la pantalla, el número real de lecturas se dispara en poco tiempo a varias veces N.

Hay dos soluciones.

  1. Reducir el número de idas y vueltas con consultas por lotes. El operador whereIn de Firestore solo admite hasta 30 valores por consulta, así que hay que dividir en bloques de 30 y, sobre todo, cachear los resultados en un Map para reutilizarlos.
final userIds = chats.map((c) => c.otherUserId).toSet().toList();
final chunks = [
  for (var i = 0; i < userIds.length; i += 30)
    userIds.sublist(i, (i + 30).clamp(0, userIds.length)),
];
final userDocs = await Future.wait(chunks.map((chunk) =>
  FirebaseFirestore.instance
      .collection('users')
      .where(FieldPath.documentId, whereIn: chunk)
      .get(),
));
  1. Más de fondo, eliminar la consulta directamente mediante desnormalización (denormalization). Guarda campos que se usan con frecuencia, como otherUserName u otherUserAvatarUrl, directamente dentro del documento chats/{chatId}, y actualiza los documentos relacionados solo cuando cambie el perfil, mediante un trigger de Cloud Functions. Como los cambios de perfil son poco frecuentes y las consultas de la lista son constantes, este es el típico intercambio de NoSQL: aumentar unas pocas escrituras a cambio de eliminar por completo N lecturas.

Antipatrón 2: el listener de StreamBuilder se reconecta en cada rebuild del widget

Este es el patrón más difícil de detectar y, a la vez, el más caro. Si la llamada a .snapshots() se coloca de forma inline dentro del método build(), cada vez que se invoca build se crea una nueva instancia del stream y se descarta el listener anterior. Y como establece la regla oficial citada antes, cuando un listener se conecta de nuevo, se factura de nuevo la lectura completa del conjunto de resultados.

// Antipatrón: nuevo stream en cada build() → el listener se reconecta cada vez
class ChatRoomScreen extends StatelessWidget {
  final String roomId;
  const ChatRoomScreen({required this.roomId});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance
          .collection('rooms/$roomId/messages')
          .orderBy('timestamp', descending: true)
          .limit(50)
          .snapshots(), // este stream se vuelve a crear cada vez que el widget padre hace setState
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

Cada vez que un cambio de estado ajeno a la pantalla del chat —un indicador de “escribiendo”, un contador de badges, un AnimationController— provoca el rebuild del widget padre, este StreamBuilder se recrea junto con él, y los 50 mensajes recientes que ya se habían cargado se vuelven a leer desde cero.

La solución es desacoplar la instancia del stream del ciclo de vida del widget y crearla una sola vez.

class ChatRoomScreen extends StatefulWidget {
  final String roomId;
  const ChatRoomScreen({required this.roomId});

  @override
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}

class _ChatRoomScreenState extends State<ChatRoomScreen> {
  late final Stream<QuerySnapshot> _messagesStream;

  @override
  void initState() {
    super.initState();
    // initState solo se llama una vez durante todo el ciclo de vida del widget.
    _messagesStream = FirebaseFirestore.instance
        .collection('rooms/${widget.roomId}/messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: _messagesStream, // se reutiliza el mismo stream aunque haya rebuild
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

Si usas Riverpod, puedes conseguir el mismo efecto de forma más explícita con StreamProvider.family. El provider cachea el stream por roomId, y aunque el widget se reconstruya, un simple ref.watch no vuelve a crear el stream.

final messagesProvider =
    StreamProvider.family<QuerySnapshot, String>((ref, roomId) {
  return FirebaseFirestore.instance
      .collection('rooms/$roomId/messages')
      .orderBy('timestamp', descending: true)
      .limit(50)
      .snapshots();
});

Añadir o no autoDispose es una decisión de compromiso. Si quieres cerrar el listener en cuanto el usuario sale de la sala de chat, autoDispose es la opción correcta; pero si el patrón habitual es salir brevemente de la pantalla y volver enseguida, como al cambiar de pestaña, conviene combinar autoDispose con keepAlive() y dar un pequeño margen de gracia, para evitar pagar el coste de reconexión cada vez que se cambia de pestaña.

Antipatrón 3: usar get() como polling en lugar de un listener en tiempo real

Algunos equipos, al considerar que no necesitan la naturaleza en tiempo real de .snapshots(), optan por llamar a .get() cada pocos segundos con Timer.periodic. A primera vista parece razonable pensar que “al no usar un listener, esto debería salir más barato”, pero en realidad ocurre justo lo contrario.

// Antipatrón: polling cada 5 segundos — se relee todo el conjunto de resultados aunque no haya cambios
Timer.periodic(const Duration(seconds: 5), (_) async {
  final snap = await FirebaseFirestore.instance
      .collection('rooms/$roomId/messages')
      .orderBy('timestamp', descending: true)
      .limit(50)
      .get();
  updateUi(snap.docs);
});

Un listener de .snapshots(), tras la conexión inicial, solo factura los documentos que cambian, pero el polling con .get() vuelve a leer todo el conjunto de resultados en cada llamada, haya cambiado algo o no. Si se hace polling 12 veces por minuto (cada 5 segundos) y el resultado tiene 50 documentos, se generan 600 lecturas por minuto y 36.000 por hora, aunque no haya habido ningún cambio. Si la pantalla realmente necesita datos en tiempo real, usar .snapshots() desde el principio es estructuralmente más barato; y si de verdad hace falta polling (por ejemplo, para una sincronización por lotes), hay que espaciar el intervalo, minimizar el limit, o acotar la consulta con un cursor basado en updated_at para traer solo lo que ha cambiado.

Ejemplo de cálculo real: cuánto sube la factura de una app de chat con 5.000 DAU por un solo antipatrón

Calculemos el impacto tomando como referencia el antipatrón 2 (reconexión de listeners), el que más repercusión tiene de los tres. Antes de nada, aclaremos que las siguientes hipótesis son un modelo simplificado del tráfico real de una app de chat, no un benchmark exacto, sino un ejemplo para hacerse una idea del orden de magnitud en que se dispara el coste.

Hipótesis

Cálculo

Lecturas adicionales por usuario y día = 10 reconexiones × 50 documentos = 500 lecturas
5.000 DAU × 500 lecturas = 2.500.000 lecturas adicionales al día

Lecturas adicionales al mes (30 días) = 2.500.000 × 30 = 75.000.000 (75 millones)

Coste adicional = 75.000.000 / 100.000 × $0,06 = 750 × $0,06 = $45/mes

¿Qué ocurre si esta app crece hasta 100.000 DAU? Manteniendo el mismo antipatrón y el mismo múltiplo de lecturas por usuario (500 lecturas/día):

100.000 DAU × 500 lecturas = 50.000.000 lecturas adicionales al día
Lecturas adicionales al mes = 50.000.000 × 30 = 1.500.000.000 (1.500 millones)
Coste adicional = 1.500.000.000 / 100.000 × $0,06 = 15.000 × $0,06 = $900/mes

Si el DAU se multiplica por 20, el coste adicional causado por este antipatrón también se multiplica exactamente por 20 (de $45 a $900 al mes). El desperdicio provocado por la reconexión de listeners es proporcional de forma lineal al número de usuarios, lo que dibuja la curva típica de estos problemas: “pasa desapercibido con pocos usuarios, pero se vuelve alarmante en cuanto la app escala”. Y en la práctica, es raro que una app real tenga un solo antipatrón. Cuando el N+1 y la reconexión de listeners coexisten, ambos desperdicios se multiplican entre sí, generando una factura mucho más pronunciada que el cálculo anterior, porque en esa estructura se multiplican a la vez el número de elementos de la lista (N), el número de listeners por elemento (L) y la frecuencia de reconexión (F).

Calcular en qué punto se agota la cuota diaria gratuita (50.000 lecturas)

Incluso una app “todavía pequeña” con unos 5.000 DAU puede agotar la cuota gratuita de 50.000 lecturas mucho más rápido de lo que parece. Se puede calcular con la siguiente fórmula.

Usuarios concurrentes (C) × listeners promedio por sesión (L) × documentos leídos por listener en la conexión inicial (R) × factor de reconexión (F)
= lecturas totales al día

Usuarios concurrentes máximos sin superar la cuota gratuita: C_max = 50.000 / (L × R × F)

Ejemplo 1: un estado “saludable” sin antipatrón de reconexión — con una lista de chats (1 listener, 20 resultados) y la sala de chat actual (1 listener, 50 resultados), tenemos L=2, R promedio=35 y F=1 (sin reconexiones):

C_max = 50.000 / (2 × 35 × 1) = 50.000 / 70 ≈ 714 usuarios

Con hasta unos 714 usuarios concurrentes, la app se mantiene dentro de la cuota gratuita.

Ejemplo 2: el mismo escenario, pero con el antipatrón de reconexión calculado antes — con el mismo L=2 y R=35, pero con un promedio de 5 reconexiones por sesión (F=5):

C_max = 50.000 / (2 × 35 × 5) = 50.000 / 350 ≈ 142 usuarios

Solo por la reconexión de listeners, el número de usuarios concurrentes que la cuota gratuita puede sostener cae de 714 a 142, es decir, a una quinta parte. Aquí está la razón por la que la suposición de “nuestra app todavía tiene solo unos cientos de usuarios, así que la cuota gratuita nos sobra” se rompe en la práctica, a veces antes incluso de mediodía, por culpa de un solo antipatrón. Y conviene recordar también que la cuota gratuita la comparte toda la base de datos default del proyecto: si el entorno de staging y el de producción usan el mismo proyecto de Firebase, incluso las pruebas de hot reload de un desarrollador consumen esa misma cuota.

Patrones prácticos para reducir el coste

Además de las correcciones de código concretas ya mencionadas, aquí van patrones aplicables a nivel de arquitectura.

final cached = await docRef.get(const GetOptions(source: Source.cache));
Query query = FirebaseFirestore.instance
    .collection('posts')
    .orderBy('createdAt', descending: true)
    .limit(20);

if (lastDocument != null) {
  query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();

Comparativa de precios con alternativas a Firestore: Cloudflare D1 y Supabase Postgres

Por último, para responder a la pregunta de “si hay tantas lecturas, ¿no encajaría mejor otro backend desde el principio”, repasamos los precios oficiales de dos alternativas.

Concepto Firestore (Blaze) Cloudflare D1 Supabase Postgres
Cuota gratuita de lectura 50.000/día 5 millones de filas/día (plan Workers Free) Las peticiones a la API no tienen límite en sí (solo hay límites de capacidad y rendimiento)
Almacenamiento gratuito 1 GiB 5GB (por cuenta) 500MB
Precio de lectura por exceso $0,06 por 100.000 Incluido hasta 25.000 millones de filas/mes en el plan Workers Paid ($5/mes); el exceso cuesta $0,001 por millón de filas Se escala subiendo de plan (almacenamiento, conexiones); no hay facturación por petición
Precio de escritura por exceso $0,18 por 100.000 Incluido hasta 50 millones de filas/mes en el plan Workers Paid; el exceso cuesta $1,00 por millón de filas Igual que arriba
Listener nativo en tiempo real Sí (stream basado en WebSocket, y precisamente la causa de la estructura de costes tratada en este artículo) No de forma nativa (se puede implementar a mano con Durable Objects) Ofrece función Realtime (basada en replicación lógica de Postgres, con límites propios)

A primera vista, el precio de exceso de D1 parece abrumadoramente más barato que el de Firestore ($0,001 por millón de filas frente a $0,60 por millón de lecturas), pero hay que subrayar que no es una comparación entre productos equivalentes. D1 factura por fila SQL y exige contratar el plan Workers Paid de $5/mes, y no sustituye tal cual el listener en tiempo real, la sincronización offline y el autoescalado que Firestore ofrece de serie. Lo mismo ocurre con Supabase: aunque tiene función Realtime, viene con límites propios de conexiones concurrentes y de mensajes, así que tampoco es un reemplazo directo, uno a uno, de .snapshots() en Firestore.

La conclusión práctica y razonable es esta: deja en Firestore (o en una base de datos en tiempo real equivalente) solo lo que de verdad necesita sincronización en tiempo real, como los mensajes de chat o la edición colaborativa, y traslada el resto de datos —los que se leen mucho pero no necesitan ser en tiempo real— a un backend más barato como D1 o Supabase. Solo con esta separación se puede evitar de raíz buena parte del desperdicio de “entre $45 y $900 al mes por un antipatrón” que calculamos antes.

Checklist final

Para mantener bajo control la factura de la combinación Flutter+Firestore, se recomienda revisar lo siguiente, en este orden.

Con solo revisar estos seis puntos, se puede prevenir la mayoría de los incidentes en los que “la factura se dispara de repente sin haber cambiado el código”.