Cloudflare R2 vs S3 vs Firebase Storage: con 1TB de descarga al mes, $0 vs $90 vs $108 — elegir el almacenamiento por el costo de egress

Cuando se agrega una función de subida de imágenes a una app Flutter, casi todo el mundo busca cosas como “comparación de precios S3 vs Firebase Storage” o “costo de almacenamiento por GB en R2”. Y como las tarifas de almacenamiento por GB que aparecen en esas tablas son prácticamente idénticas entre sí, se termina concluyendo “usemos el que ya conocemos”. Pero cuando el servicio crece y los usuarios empiezan a mirar fotos de perfil e imágenes del feed todos los días, la línea más grande de la factura deja de ser el almacenamiento y pasa a ser el costo de descarga (egress). Este artículo demuestra esa cifra con tarifas reales.
Resumen clave: el costo de almacenamiento ronda unos pocos centavos por GB, así que los tres servicios están prácticamente empatados ahí. Pero el costo de egress (descarga) pertenece a otro orden de magnitud. Cloudflare R2 tiene el egress estructuralmente en $0, AWS S3 cobra $0,09 por GB, y Firebase Storage cobra alrededor de $0,12 por GB una vez superados los 100GB gratuitos mensuales. Con solo 1TB de tráfico de descarga al mes, R2 se queda en $0, S3 sube a unos $90 y Firebase Storage a unos $108. Cuanto más dependa la app de imágenes y video, más se dispara esta brecha: de forma lineal respecto al tráfico, y de forma casi exponencial en cómo se percibe.
Por qué el problema no es el almacenamiento, sino el egress
La respuesta aparece en cuanto se piensa en el patrón de tráfico que genera el almacenamiento de imágenes/video en una app Flutter. Una imagen se sube una sola vez, pero se consulta cientos o miles de veces. Una foto de perfil se carga tantas veces como seguidores tenga esa persona, y una foto publicada en el feed se vuelve a descargar cada vez que alguien hace scroll o vuelve a abrir la app. Es decir, la escritura (Put/Upload) es un evento único, pero la lectura (Get/Download) acapara la mayor parte del tráfico. La tarifa de almacenamiento cobra por “cuántos GB se guardan y durante cuánto tiempo”, así que crece de forma lineal con el volumen total almacenado; la tarifa de egress, en cambio, cobra por “cuántos GB salen y con qué frecuencia”, así que se dispara mucho más rápido en función del tráfico real de uso.
Comparación rápida de la estructura de precios de los tres proveedores
Primero, un resumen de las tarifas de almacenamiento y egress según las páginas oficiales de precios de cada servicio (verificado en julio de 2026).
| Concepto | Cloudflare R2 | AWS S3 (Standard, us-east-1) | Firebase Storage (Blaze) |
|---|---|---|---|
| Tarifa de almacenamiento | $0,015/GB-mes | $0,023/GB-mes (primeros 50TB) | $0,026/GB-mes por encima de 5GB en buckets legacy; en buckets nuevos varía según la región |
| Solicitudes de escritura/modificación | Clase A: $4,50/millón | PUT, etc.: $0,005/1.000 | Aparte de Firestore; el cobro por solicitudes propias de Storage es relativamente insignificante |
| Solicitudes de lectura | Clase B: $0,36/millón | GET, etc.: $0,0004/1.000 | Igual que arriba |
| Descarga (egress) | $0 (totalmente gratis) | $0,09/GB (primeros 10TB/mes) | ~$0,12/GB por encima de 100GB/mes |
A primera vista, las tarifas de solicitud (operaciones) de R2 parecen más caras que las de S3, pero en workloads dominados por la lectura —como un visor de imágenes— la única línea del egress termina invirtiendo el resultado de todo lo demás. Lo comprobamos con números reales más adelante.
Los niveles gratuitos, en detalle: ¿de verdad son gratis?
Los tres servicios presumen de un “nivel gratuito”, pero su naturaleza es completamente distinta.
- Cloudflare R2: con almacenamiento Standard, 10GB-mes de almacenamiento gratis, 1 millón de solicitudes Clase A (escritura) gratis al mes y 10 millones de solicitudes Clase B (lectura) gratis al mes. Y al margen de este nivel gratuito, el egress es siempre gratuito, sin importar la clase de almacenamiento ni el volumen de tráfico. El tráfico de descarga que sale directamente de R2 simplemente no tiene una partida de cobro.
- Firebase Storage (Spark y Blaze por igual): en buckets nuevos (
*.firebasestorage.app), la descarga es gratis hasta 100GB al mes, y el almacenamiento hasta 5GB-mes. Al pasar al plan de pago (Blaze) esta asignación gratuita se mantiene igual. Eso sí, con la salvedad de que solo aplica en las regiones us-central1, us-west1 y us-east1. - AWS S3: sí existe un “100GB al mes de transferencia de datos salientes a internet gratis”. Pero, citando literalmente la documentación oficial, esos 100GB están “aggregated across all AWS Services and Regions”, es decir, es un único pool que se suma entre toda la cuenta, todas las regiones y todos los servicios. Da igual que una instancia EC2 devuelva respuestas de una API, que CloudFront tire de S3 como origen por un cache miss, o que se descargue un backup de RDS: todo consume el mismo pool de 100GB. En una cuenta de laboratorio que solo usa almacenamiento este nivel gratuito puede tener sentido, pero en una cuenta de AWS en producción donde ya corren otros workloads, lo más seguro es asumir que ese pool está prácticamente siempre agotado.
Esta diferencia también explica por qué, en la tabla de simulación que sigue, las cifras de S3 y Firebase Storage en el tramo de bajo tráfico no coinciden entre sí.
Simulación de factura real: 100GB / 1TB / 10TB de descarga al mes
La siguiente tabla calcula la factura mensual considerando únicamente el tráfico de egress. Las condiciones del cálculo se fijaron explícitamente así:
- Se simplifica 1TB = 1.000GB (en la factura real las unidades se acercan más a GiB/TiB, pero aquí se usa el sistema decimal para comparar órdenes de magnitud).
- R2: el egress siempre es $0. (El costo de almacenamiento y de operaciones se trata en una sección aparte).
- S3: se asume que el nivel gratuito compartido de toda la cuenta (100GB) ya está consumido por otro tráfico de AWS, y se aplica directamente la tarifa del tramo de los primeros 10TB/mes, $0,09/GB.
- Firebase Storage: se asume que la asignación gratuita exclusiva de Storage (100GB/mes) se renueva cada mes solo para esta app, y se aplica $0,12/GB al excedente.
| Tráfico de descarga mensual | Cloudflare R2 | AWS S3 | Firebase Storage |
|---|---|---|---|
| 100GB | $0 | 100GB × $0,09 = $9 | Dentro del límite gratuito → $0 |
| 1TB (1.000GB) | $0 | 1.000GB × $0,09 = $90 | (1.000-100)GB × $0,12 = $108 |
| 10TB (10.000GB) | $0 | 10.000GB × $0,09 = $900 | (10.000-100)GB × $0,12 = $1.188 |
Leyendo estas cifras tal cual: con 1TB de descarga al mes, S3 sale 90 veces más caro que R2, y Firebase Storage, 108 veces más caro. Y esta brecha, en términos absolutos, se ensancha aún más a medida que crece el tráfico. En el tramo de 10TB, la diferencia con R2 llega a $10.800 al año en el caso de S3 y a $14.256 al año en el de Firebase Storage.
Vale la pena aclarar que, por encima de 10TB, S3 aplica una tarifa escalonada (tiered) que baja gradualmente: $0,085/GB (10-50TB) y $0,070/GB (50-150TB). Aun así, comparado con el $0 de R2, ese “descuento” sigue siendo irrelevante.
El costo de almacenamiento y de solicitudes es comparativamente pequeño
Suponiendo que la misma app mantiene 500GB de imágenes almacenados de forma permanente:
- R2: 500GB × $0,015 = $7,5/mes
- S3: 500GB × $0,023 = $11,5/mes
- Firebase Storage: con la tarifa legacy, 500GB × $0,026 ≈ $13/mes (en buckets nuevos el precio varía por región y la página oficial no da una cifra única — si hace falta confirmarlo, hay que revisar la estimación en la consola)
Si además se revisa el costo de operaciones, incluso una app con tráfico considerable —100.000 subidas al mes (Clase A) y 50 millones de consultas (Clase B)— se queda, con R2, en $0 para Clase A (dentro del nivel gratuito de 1 millón) y en apenas $14,4/mes para Clase B, al multiplicar los 40 millones que superan el nivel gratuito (10 millones) por $0,36/millón. Es decir, tanto el costo de almacenamiento como el de solicitudes se mueven en el orden de decenas de dólares al mes. El egress, en cambio, salta a tres o cuatro cifras en cuanto el tráfico aumenta. La variable que termina decidiendo la comparación es, al final, una sola: el egress.
Un error común en apps Flutter: servir la imagen original tal cual
El patrón que más se repite en la combinación Firebase Storage + Flutter es este.
final ref = FirebaseStorage.instance.ref('posts/$postId/original.jpg');
final url = await ref.getDownloadURL();
// Se carga tal cual dentro del ListView
Image.network(url);
Una foto original tomada con la cámara suele medir 3.000×4.000px y pesar entre 3 y 8MB. Si esta se sirve sin redimensionar como miniatura del feed (donde el tamaño real en pantalla ronda los 100×100px), cada vez que el usuario hace scroll por el feed salen muchos más bytes de egress de los necesarios. Si se supone que 10.000 usuarios abren la app 3 veces al día y cada vez cargan 30 imágenes, eso da 900.000 descargas al día; con 3MB por imagen, se llega enseguida a unos 2,7TB al día, es decir, unos 81TB al mes. Al aplicar esa cifra directamente a la tarifa de Firebase Storage (~$0,12/GB tras superar los 100GB gratuitos mensuales), la factura ronda los $10.000 mensuales.
A esto se suma un problema más si se tiene en cuenta el caching. Los objetos de Firebase Storage llevan por defecto Cache-Control: public, max-age=3600 a menos que se configure algo distinto. Es decir, la caché del navegador/cliente expira al cabo de una hora, y al volver a abrir la app o revisitar la misma pantalla una hora después, el archivo original se descarga de nuevo desde cero. Aunque se use un paquete como cached_network_image para habilitar una caché en disco del lado del cliente, en el primer acceso o tras borrar la caché sigue siendo necesario descargar el original completo — esa estructura no cambia.
Orden práctico de mejoras
- Redimensionar en el momento de la subida: agregar la extensión “Resize Images” de Firebase Extensions, o generar en el servidor (Cloud Functions), mediante un trigger de subida, varios tamaños (miniatura 200px, mediano 800px, original) y guardar cada uno por separado. La pantalla de listado solo pide la miniatura, y el original únicamente se solicita al entrar a la pantalla de detalle.
- Reconfigurar el Cache-Control a un valor largo: incluir un hash del contenido en la ruta del archivo para volverlo inmutable, y establecer explícitamente el metadato
Cache-Control: public, max-age=31536000, immutable. Mientras el nombre del archivo no cambie, se logra una caché prácticamente permanente. - Igualar en Flutter el tamaño de visualización con el tamaño solicitado: especificar
cacheWidth/cacheHeightenImage.networkreduce el costo de decodificación, pero para reducir los bytes que realmente viajan por la red, el servidor tiene que entregar de entrada un archivo más pequeño. No hay que confundir la optimización de decodificación en el cliente con la reducción de egress: son problemas distintos. - Poner un CDN por delante: cachear las solicitudes de imágenes con un rewrite de Firebase Hosting o con un CDN aparte evita que las solicitudes repetidas de la misma imagen lleguen hasta el origen (Storage), reduciendo el egress. Eso sí, en ese caso hay que tener en cuenta que el propio CDN puede cobrar su propia tarifa de ancho de banda por separado.
Checklist de migración a R2
Como R2 ofrece una API compatible con S3, un código base existente basado en S3 puede migrarse con menos cambios de los que parece. Sin embargo, no es 100% idéntico.
1) Primero hay que revisar qué funciones no están soportadas. La API compatible con S3 de R2 soporta la gran mayoría de las operaciones de objeto principales: PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2, y la subida multiparte (CreateMultipartUpload/UploadPart/CompleteMultipartUpload). En cambio, no soporta ACL (x-amz-acl), Object Lock/versionado, etiquetado de objetos y buckets, políticas de bucket, notificaciones, logging, replicación, ni cifrado del lado del servidor SSE-KMS. Si un proyecto tiene el control de acceso construido con políticas IAM detalladas o políticas de bucket de S3, esta parte hay que rediseñarla con el modelo de permisos basado en tokens de API de Cloudflare.
2) Para la migración de datos se usa Super Slurper. Super Slurper, la herramienta que ofrece Cloudflare, migra en masa y de forma gratuita desde S3 y almacenamiento compatible con S3 (Backblaze B2, Wasabi, MinIO, DigitalOcean Spaces, entre otros, como respaldo) hacia R2. Lo único que se cobra son las operaciones de Clase A que se generan en R2 durante la migración. Sin embargo, como durante la migración los objetos pueden dividirse en varias partes al transferirse, hay que anotar en el checklist que el ETag puede no coincidir al 100% con el original (si hay código que usa el ETag para verificar integridad, hay que reemplazarlo por una verificación de hash aparte).
3) La forma de emitir presigned URLs es distinta. En R2 también se pueden generar presigned URLs directamente con el AWS SDK.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const S3 = new S3Client({
region: "auto", // R2 no tiene concepto de región, así que siempre debe ser "auto"
endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: "<ACCESS_KEY_ID>",
secretAccessKey: "<SECRET_ACCESS_KEY>",
},
});
const putUrl = await getSignedUrl(
S3,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "posts/123/original.jpg",
ContentType: "image/jpeg",
}),
{ expiresIn: 3600 }, // Se puede configurar entre 1 segundo y 7 días (604.800 segundos)
);
El cliente Flutter solo tiene que recibir esta URL desde el backend y hacer el PUT tal cual.
final res = await http.put(
Uri.parse(presignedUrl),
headers: {'Content-Type': 'image/jpeg'},
body: imageBytes,
);
Un punto a tener en cuenta: la presigned URL solo funciona en el endpoint por defecto de R2, r2.cloudflarestorage.com, y no funciona con un dominio personalizado. Lo habitual es separar la estructura: subir con presigned URL y servir las descargas públicas desde el dominio personalizado.
4) Con los bindings de Workers se elimina el origen por completo. Si el backend corre en Cloudflare Workers, se puede acceder directamente con un binding nativo sin pasar siquiera por la API compatible con S3.
# wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-media"
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1);
if (request.method === "GET") {
const obj = await env.MY_BUCKET.get(key);
return obj ? new Response(obj.body) : new Response("Not found", { status: 404 });
}
if (request.method === "PUT") {
await env.MY_BUCKET.put(key, request.body);
return new Response(`Put ${key} successfully!`);
}
return new Response("Method not allowed", { status: 405 });
},
};
Este enfoque permite procesar dentro del Worker lógica como autenticación, redimensionado o marcas de agua, y aun así conserva la ventaja de un egress que sigue siendo $0.
5) Conectar un dominio público. Los buckets de R2 son privados por defecto. En producción, lo estándar es conectar un dominio personalizado a través del DNS de Cloudflare para configurar acceso de lectura público, en lugar del subdominio de desarrollo r2.dev (que tiene rate limiting y no se recomienda para producción).
Casos en los que S3 o Firebase Storage siguen siendo mejor opción
Si se mira solo el costo de egress, R2 gana por goleada, pero en las siguientes situaciones no vale la pena migrar:
- Organizaciones AWS que ya tienen un esquema sólido de IAM/seguridad: si hay políticas de bucket, endpoints VPC, acceso cross-account o requisitos de cumplimiento normativo que dependen de Object Lock, la migración puede resultar difícil o el costo de rediseño puede superar el ahorro en egress, precisamente por las funciones que R2 no soporta.
- Ecosistemas de Firebase fuertemente acoplados a Firestore/Auth: si ya se tiene un control de acceso detallado basado en
request.auth.uidcon Firebase Storage Security Rules, o si el trigger de subida de Storage dispara Cloud Functions/Extensions (generación de miniaturas, transcodificación de video), mantener esa integración tal cual conviene más en términos de velocidad de desarrollo. Sobre todo en etapas de MVP o de startup temprana, donde el tráfico de descarga todavía ronda los 100GB al mes, la diferencia de costo de egress es tan pequeña que difícilmente justifica el costo de migrar. - Nivel de madurez de la integración con CDN: la combinación S3+CloudFront tiene funciones muy pulidas tras años de uso, como cookies firmadas, control de acceso por región o Lambda@Edge. R2 también se integra con el CDN de Cloudflare, pero si ya existe mucha lógica de invalidación de caché y de edge diseñada en torno a CloudFront, conviene calcular aparte el costo del cambio.
- Conocimiento operativo del equipo y su cadena de herramientas: si los módulos de Terraform, los dashboards de monitoreo y las reglas de alertas ya están armados sobre AWS/GCP, el ahorro en egress puede terminar siendo menor que el costo de reconstruir todo ese activo operativo. En ese caso, lo más realista es una migración gradual: adoptar R2 primero solo en los pipelines de medios nuevos y dejar los buckets existentes tal como están.
Conclusión: el criterio de decisión es el perfil de tráfico
Las tarifas de almacenamiento de los tres servicios rondan entre 1 y 3 centavos por GB, así que ahí son prácticamente equivalentes. La decisión termina definiéndose por cuánto tráfico de descarga sale y cuánto se cobra por él.
- Si la descarga mensual es baja, por debajo de 100GB (MVP inicial, herramienta interna), la diferencia de costo entre los tres servicios es insignificante sin importar cuál se use. En ese caso, conviene priorizar la integración con el ecosistema ya existente (Firebase Auth, AWS IAM).
- En el momento en que la descarga mensual supera 1TB, la brecha entre R2 y los otros dos servicios pasa a ser de dos dígitos de múltiplo. Si se trata de una app Flutter social o de comercio donde las imágenes y el video son el contenido central, ese es el punto en el que hay que evaluar en serio la migración a R2.
- Para servicios con más de 10TB de descarga, la diferencia de costo de egress se cuenta en decenas de miles de dólares al año, así que conviene sacar una cotización real usando el checklist de migración descrito antes (limitaciones de la API compatible con S3, Super Slurper, presigned URLs, bindings de Workers).
Los números no mienten. Cuanto más crece el tráfico, una sola frase —“el egress es gratis”— termina definiendo toda la factura.