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

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

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.

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í:

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:

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

  1. 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.
  2. 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.
  3. Igualar en Flutter el tamaño de visualización con el tamaño solicitado: especificar cacheWidth/cacheHeight en Image.network reduce 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.
  4. 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:

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.

Los números no mienten. Cuanto más crece el tráfico, una sola frase —“el egress es gratis”— termina definiendo toda la factura.