Cloudflare Images a fondo: redimensiona, convierte y sirve imágenes desde el borde

Cómo funciona Cloudflare Images: transformaciones por URL, por fetch o con el binding de Workers, almacenamiento con variantes, precios por transformación única y límites. Con un Worker que sirve miniaturas WebP y AVIF desde R2 probado en local.

Portada: Cloudflare Images a fondo

Las imágenes suelen ser lo que más pesa de una web. Subir una foto de 4000 píxeles y mostrarla a 400 es tirar ancho de banda; servir JPEG a un navegador que entiende AVIF, también. La solución clásica es generar tú mismo todas las versiones en cada subida, guardarlas y elegir cuál servir. Funciona, pero es trabajo y ocupa espacio.

Cloudflare Images hace las versiones al vuelo: pides la imagen con el tamaño y el formato que necesitas y Cloudflare la redimensiona, la convierte y la cachea. Sirve tanto para imágenes que ya tienes en tu servidor o en R2 como para imágenes que subes a su propio almacenamiento.

En este artículo vemos las formas de usarlo, cómo se factura (que es lo que más confunde), sus límites y un Worker que lee originales de R2 y sirve miniaturas en WebP o AVIF con el binding de Images, probado en local.

🧪
El Worker de este artículo se ha ejecutado de verdad el 3 de octubre de 2026 con Wrangler 4.147.0 y Node.js 24.21.0, usando el binding de Images en su versión local de baja fidelidad, que solo soporta parte de las funciones (redimensionar, rotar, convertir). Las transformaciones por URL, el almacenamiento de Images con variantes y las superposiciones (.draw()) no los he probado: necesitan una zona o una cuenta. Precios y límites comprobados en la documentación oficial ese mismo día.

Cómo funciona

Hay dos ideas separadas: transformar (redimensionar, recortar, convertir de formato, desenfocar, añadir una marca de agua…) y almacenar. Puedes transformar imágenes que están en cualquier sitio, o guardarlas en Images y servirlas desde allí.

Esquema de Cloudflare Images: el original puede estar en tu servidor, en R2 o en el almacenamiento de Images; se transforma por URL /cdn-cgi/image, con fetch y opciones cf.image en un Worker, o con el binding IMAGES; el resultado se entrega en el formato y tamaño pedidos
Tres formas de transformar, dos sitios donde puede vivir el original. Diagrama: devutilites.

Tres formas de transformar

  1. Por URL. Activas las transformaciones en tu zona y pides la imagen con las opciones en la ruta: https://tudominio.com/cdn-cgi/image/width=400,format=auto/fotos/playa.jpg. Por defecto solo acepta originales de tu propia zona; puedes añadir otros orígenes permitidos.
  2. Con fetch en un Worker. Haces un fetch a la imagen original con cf: { image: { width: 400, format: "avif" } }. Te deja decidir las opciones en código (según el dispositivo, el usuario, la ruta).
  3. Con el binding IMAGES. Le pasas los bytes de la imagen (de R2, de una subida, de un fetch) y encadenas operaciones: .input(), .transform(), .draw() y .output(). No necesita que el original tenga URL pública y te deja controlar el orden de las operaciones.

Las opciones disponibles son las mismas en todos los casos: width, height, fit, gravity (incluido recorte centrado en caras), quality, format (auto elige AVIF o WebP según el navegador), rotate, blur, sharpen, trim, background, onerror y unas cuantas más.

Almacenamiento con variantes

Si subes las imágenes a Images (por el panel, la API, una URL de subida directa o el binding), se sirven desde https://imagedelivery.net/<hash de tu cuenta>/<id de imagen>/<variante>. Una variante es un preset con nombre (miniatura, public, portada) que define tamaño y ajuste. También puedes servir imágenes privadas con URL firmadas.

Precios

Por defecto todas las cuentas están en el plan Free de Images, que incluye las transformaciones de imágenes que guardas fuera (tu servidor, R2), pero no el almacenamiento en Images.

ConceptoFreePaid
Transformaciones únicas5000 al mes5000 incluidas, después 0,50 $ por cada 1000
Imágenes almacenadas en ImagesNo disponible5 $ por cada 100 000 al mes
Imágenes entregadas desde ImagesNo disponible1 $ por cada 100 000 al mes

La clave está en qué es una transformación única: una combinación de imagen original y parámetros, contada una sola vez por mes. playa.jpg a 100×100 es una; a 200×200, otra. Pedir mil veces la misma versión en el mismo mes cuenta como una. Y format=auto cuenta como una aunque a unos usuarios les llegue AVIF y a otros WebP.

En el plan Free, al pasar de 5000 transformaciones únicas en el mes las que ya están en caché se siguen sirviendo, pero las nuevas devuelven el error 9422; no te cobran nada. Si el original está en tu misma zona, onerror=redirect sirve el original en ese caso.

El ejemplo oficial: 2000 imágenes en 5 tamaños son 10 000 transformaciones únicas; menos las 5000 incluidas, 5000 × 0,50 $ / 1000 = 2,50 $ al mes, más lo que cueste guardar los originales en R2.

Límites

LímiteImágenes remotasImágenes en Images
Tamaño del fichero100 MB10 MB
Área máxima100 megapíxeles100 megapíxeles
Lado máximo (salvo WebP y AVIF)12 000 px12 000 px
Lado máximo en AVIF1200 px1200 px
Metadatos—1024 bytes

Con el binding, .input() acepta hasta 20 MB. Las animaciones de más de 50 megapíxeles en total (sumando fotogramas) se entregan sin transformar; para eso es mejor usar vídeo.

El código

Un Worker que guarda los originales en R2 y sirve versiones bajo demanda. La configuración:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "images-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "images": { "binding": "IMAGES" },
  "r2_buckets": [{ "binding": "ORIGINALES", "bucket_name": "fotos-originales" }]
}

Y el Worker. /info/<clave> devuelve los datos del original sin transformarlo; /img/<clave>?w=400&f=webp, la versión pedida:

// Formatos de salida que permitimos y sus tipos MIME
const FORMATOS = { webp: "image/webp", avif: "image/avif", jpeg: "image/jpeg", png: "image/png" } as const;
type Formato = keyof typeof FORMATOS;

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    // /img/<clave>?w=400&f=webp   o   /info/<clave>
    const [, accion, ...resto] = url.pathname.split("/");
    const clave = decodeURIComponent(resto.join("/"));

    const original = await env.ORIGINALES.get(clave);
    if (!original) return new Response("No existe", { status: 404 });

    if (accion === "info") {
      // Formato, peso y dimensiones del original, sin transformarlo
      return Response.json(await env.IMAGES.info(original.body));
    }

    if (accion === "img") {
      // Lista cerrada de anchos: cada combinación distinta es una transformación facturable
      const ancho = [200, 400, 800, 1200].includes(Number(url.searchParams.get("w")))
        ? Number(url.searchParams.get("w"))
        : 800;
      const f = (url.searchParams.get("f") ?? "webp") as Formato;
      if (!(f in FORMATOS)) return new Response("Formato no permitido", { status: 400 });

      const resultado = await env.IMAGES.input(original.body)
        .transform({ width: ancho })
        .output({ format: FORMATOS[f], quality: 80 });

      return resultado.response({
        headers: { "Cache-Control": "public, max-age=86400" },
      });
    }

    return new Response("Rutas: /img/<clave>?w=&f= y /info/<clave>", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

La lista cerrada de anchos no es un capricho: como cada combinación distinta es una transformación facturable, dejar que el cliente pida w=401, w=402… permitiría a cualquiera gastar tu cupo.

Ejecución en local

Subí al R2 local una de las portadas de esta serie (1600×900, JPEG) con npx wrangler r2 object put fotos-originales/portadas/workers.jpg --file foto.jpg --local y probé varias combinaciones:

$ curl -s localhost:8810/info/portadas/workers.jpg
{"format":"image/jpeg","fileSize":113724,"width":1600,"height":900}
$ curl -s -o salida.img -w "%{http_code} %{content_type} %{size_download} bytes\n" "localhost:8810/img/portadas/workers.jpg?w=400&f=webp"
200 image/webp 6440 bytes
$ curl -s -o salida.img -w "%{http_code} %{content_type} %{size_download} bytes\n" "localhost:8810/img/portadas/workers.jpg?w=200&f=avif"
200 image/avif 1869 bytes
$ curl -s -o salida.img -w "%{http_code} %{content_type} %{size_download} bytes\n" "localhost:8810/img/portadas/workers.jpg?w=1200&f=jpeg"
200 image/jpeg 61221 bytes
$ curl -s -o salida.img -w "%{http_code} %{content_type} %{size_download} bytes\n" "localhost:8810/img/portadas/workers.jpg?w=999&f=png"
200 image/png 164819 bytes
$ curl -s -o salida.img -w "%{http_code} %{content_type} %{size_download} bytes\n" "localhost:8810/img/portadas/workers.jpg?w=400&f=gif"
400 text/plain;charset=UTF-8 20 bytes

De 111 KB a 6,4 KB en WebP a 400 px y 1,9 KB en AVIF a 200 px. Comprobé el WebP con otra herramienta: mide 400×225, así que respeta la proporción. El ancho no permitido (999) cae al valor por defecto (800) y el GIF se rechaza.

Dos avisos sobre lo local. Wrangler usa una versión de baja fidelidad que no implementa todo; para probar el servicio real está wrangler dev --remote, que ya gasta transformaciones. Y las respuestas del binding no se cachean solas: la documentación recomienda activar la caché de Workers en la configuración ("cache": { "enabled": true }) y poner Cache-Control, como hace el ejemplo con .response({ headers }). Esa opción de configuración no la he probado.

Errores comunes (y cómo evitarlos)

  • Dejar los parámetros libres. Cada combinación nueva es una transformación facturable. Fija una lista de tamaños.
  • No cachear la salida del binding. Cada petición sin caché decodifica y vuelve a codificar la imagen.
  • Pedir AVIF enormes. AVIF está limitado a 1200 px por lado. Para imágenes grandes, usa format=auto o WebP.
  • Abrir las transformaciones por URL a cualquier origen. Cualquiera podría usar tu zona para transformar imágenes de otros. Limita los orígenes permitidos.
  • Confundir planes. El plan Free solo transforma imágenes guardadas fuera; para guardarlas en Images necesitas el Paid.
  • Servir GIF animados grandes. Pesan mucho y por encima de 50 megapíxeles no se transforman. Convierte a MP4 o WebM.

Cuándo usar Images (y cuándo no)

Úsalo para servir imágenes responsive sin generar versiones a mano, para convertir automáticamente a AVIF o WebP, para miniaturas de contenido que suben tus usuarios (originales en R2, versiones al vuelo) y para marcas de agua o recortes centrados en caras.

No lo necesitas si tienes pocas imágenes fijas que ya optimizas al compilar la web, ni si tu CMS ya genera y sirve las versiones (Ghost, por ejemplo, genera sus propios tamaños). Para vídeo, el producto es Stream.

Sigue leyendo