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
- El precio de Firestore en el plan Blaze (para regiones multi-región como nam5/eur3; verifica siempre la documentación oficial) es de $0,06 por cada 100.000 lecturas, $0,18 por cada 100.000 escrituras y $0,02 por cada 100.000 eliminaciones. Un millón de lecturas cuesta $0,60 — la cifra parece pequeña, pero cuando se acumulan reconexiones de listeners, ese precio unitario se multiplica de forma alarmante.
- La cuota diaria gratuita es de 50.000 lecturas, 20.000 escrituras, 20.000 eliminaciones y 1 GiB de almacenamiento, y este límite se aplica a una única base de datos “default” por proyecto. No es un límite por app, sino que todo el tráfico del proyecto lo comparte.
- Un listener en tiempo real factura como una lectura cada documento del conjunto de resultados en el momento de la conexión inicial, y a partir de ahí solo factura los documentos que cambian. El problema es que, si la persistencia offline está desactivada, cada vez que el listener se desconecta y se vuelve a conectar, se trata como una consulta completamente nueva y se vuelven a leer todos los documentos. En Flutter, esta reconexión puede ocurrir con la simple reconstrucción de un widget.
- El patrón N+1, que consiste en consultar un documento distinto por cada elemento de una pantalla de lista; la reconexión de listeners, que crea un nuevo stream en cada rebuild del widget; y el patrón de polling, que llama repetidamente a
.get()en lugar de usar un listener en tiempo real —estas tres son las causas más habituales de que la factura se dispare en la combinación Flutter+Firestore. - La solución pasa por una arquitectura híbrida: cachear la instancia del listener, aplicar paginación basada en cursores, eliminar el N+1 mediante desnormalización, y trasladar los datos de solo lectura y baja variabilidad a un backend mucho más barato como Cloudflare D1/KV.
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.
- Una consulta se factura como mínimo con 1 lectura aunque el resultado tenga 0 documentos. La suposición de que “consultar algo que no existe es gratis” es incorrecta.
- Las consultas complejas se facturan con una lectura adicional por cada 1.000 entradas de índice escaneadas. Las consultas de agregación, como
count(), siguen la misma regla: incluso si no se lee ninguna entrada de índice, se factura como mínimo 1 lectura. - La cuota gratuita se aplica únicamente a la base de datos “default” del proyecto. Si creas una base de datos adicional dentro del mismo proyecto, esa base de datos se factura desde el primer documento, sin cuota gratuita.
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.
- Reducir el número de idas y vueltas con consultas por lotes. El operador
whereInde Firestore solo admite hasta 30 valores por consulta, así que hay que dividir en bloques de 30 y, sobre todo, cachear los resultados en unMappara 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(),
));
- Más de fondo, eliminar la consulta directamente mediante desnormalización (denormalization). Guarda campos que se usan con frecuencia, como
otherUserNameuotherUserAvatarUrl, directamente dentro del documentochats/{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
- 5.000 usuarios activos diarios (DAU)
- Cada usuario abre en promedio 1 sesión de sala de chat al día, y durante esa sesión se producen en promedio 10 reconexiones del listener por rebuild del widget padre (causadas por cambios de estado ajenos al chat, como el indicador de “escribiendo” o las marcas de leído)
- Cada reconexión vuelve a leer desde cero los 50 mensajes más recientes (
limit(50))
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.
- Activa explícitamente el caching en el cliente.
cloud_firestoretiene la persistencia offline activada por defecto (en móvil), pero conviene revisar de forma explícita el tamaño y la política de la caché, y en las pantallas donde no hace falta información en tiempo real, usarGetOptions(source: Source.cache)para saltarse por completo el viaje de ida y vuelta al servidor.
final cached = await docRef.get(const GetOptions(source: Source.cache));
- Aplica siempre paginación basada en cursores en las pantallas de lista, sin excepción. El código que se suscribe a una colección entera sin
limit()hace que el coste de la lectura inicial crezca a la par que crece la colección.
Query query = FirebaseFirestore.instance
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(20);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();
- Reduce el N+1 de raíz mediante actualizaciones parciales y desnormalización. En lugar de sobrescribir el documento entero con
.set(), usa.update()para escribir solo los campos que cambian, y guarda los datos relacionados que se consultan con frecuencia junto al documento padre, tal como se explicó antes. - Haz que el ciclo de vida del listener lo posea la capa de gestión de estado, no el árbol de widgets. Crea el stream una sola vez en
initState, o cachéalo a nivel de provider con Riverpod/Bloc, para separar el rebuild de la reconexión del listener. Esta es la medida con mayor impacto de todo el artículo, porque ataca de raíz el antipatrón más costoso de los tres. - Saca de Firestore los datos de solo lectura y baja variabilidad. Deja en Firestore únicamente los datos que de verdad necesitan sincronización en tiempo real (mensajes de chat, presencia, edición colaborativa), y evalúa una arquitectura híbrida que traslade a un backend mucho más barato —como Cloudflare Workers + D1 (o KV)— los datos que se leen con frecuencia pero no necesitan ser en tiempo real, como configuraciones estáticas, catálogos de productos o leaderboards. La app Flutter simplemente llamaría a ese endpoint del Worker mediante REST en lugar de usar el SDK de Firestore.
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.
- ¿La llamada a
.snapshots()está colocada de forma inline dentro del métodobuild()(en vez de eninitStateo a nivel de provider)? - ¿Se está haciendo una llamada
get()separada por cada elemento de una pantalla de lista (en lugar de sustituirla por consultas en lote o desnormalización)? - ¿Queda algún
Timer.periodiccon polling mediante.get()(que se podría sustituir por un listener en tiempo real)? - ¿Todas las suscripciones a listas o colecciones tienen aplicados
limit()y un cursor de paginación? - ¿Staging y producción comparten el mismo proyecto de Firebase (y, por tanto, la misma cuota gratuita)?
- ¿Hay margen para sacar de Firestore los datos de solo lectura que no necesitan tiempo real?
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”.