Según todas las métricas de cajón, mi sitio era rápido. Astro estático, 15.5KB de JavaScript en total — un solo archivo — y 73KB de CSS. El tipo de números que hacen que dejes de revisar. Hasta que medí de verdad el build de producción y descubrí que estaba enviando 3.37MB de imágenes. Cerca del 90% de todo lo que un visitante podía descargar eran bytes de imagen, y tres archivos concentraban casi todo.
Lo interesante no es que las imágenes fueran grandes. Es que cada uno de los tres archivos se saltó la optimización de imágenes de Astro de una forma distinta — y ninguno produjo una advertencia, un error de build ni una página visiblemente rota. Un framework rápido con pipeline de imágenes no es lo mismo que un sitio rápido. Aquí va cómo se coló cada uno, y el arreglo de 15 líneas.
El comando de una línea que encontró el problema
El error que yo venía cometiendo era mirar src/. Lo que descargan tus visitantes es dist/, así que eso es lo que se mide:
bun run build
find dist -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \
-o -name '*.webp' -o -name '*.avif' \) -exec du -b {} + | sort -rn | headEl mío regresó con tres culpables hasta arriba:
| Archivo | Peso en dist/ | Qué era |
|---|---|---|
ubuntu-cycling-camp-landingpage.png | 1.66MB | Una captura de pantalla del portafolio, publicada tal cual |
me.jpg | 934KB | Una foto de cámara a resolución completa mostrándose como avatar de ~500px |
hero-image.png | 539KB | Material del template que nada referenciaba |
Cada uno es un modo de fallo distinto, y vale la pena entender los tres, porque tu sitio casi seguro tiene al menos uno.
Modo de fallo #1: el pipeline nunca reduce tu original
me.jpg era una foto directa de la cámara: 3456×4608 píxeles, 934KB, mostrándose como un avatar de unos 500px de ancho. Y aquí está el detalle — Astro estaba haciendo su trabajo. El build generaba variantes WebP en varios anchos y las conectaba en un srcset correcto. Los navegadores modernos elegían una variante pequeña. Parecía un problema resuelto.
Pero el archivo original se seguía publicando, byte por byte. El pipeline de imágenes de Astro construye variantes por debajo de tu fuente; nunca reduce el fallback en sí. El src al que un navegador recurre — y la URL que los scrapers, los clientes viejos y algunos webviews dentro de apps de verdad descargan — era mi original de 934KB. Lo verifiqué en el build actual: el me.jpg con hash en dist/ es idéntico byte por byte al archivo fuente.
La regla que se desprende: tu archivo fuente es el piso de lo que un visitante puede llegar a descargar. Ningún optimizador posterior cambia eso. Si la fuente pesa 10× el tamaño más grande al que la muestras, siempre estás a un fallback de servir 10× los bytes. En mi página de inicio ese avatar carga con eager — es el elemento LCP — así que ese piso cargaba con todo el peso.
Modo de fallo #2: la captura que se publicó tal cual
La captura del portafolio era un PNG de 1898×1027 a 1.66MB, y a diferencia del avatar, el pipeline no le dio nada: ni variantes, ni WebP, ni srcset. Pasó de src/assets/ a dist/ sin tocarse, y cada visitante recibía sus 1.66MB completos.
Este es el silencioso. La página se veía perfecta. No existe una advertencia de build que diga “esta imagen se saltó la optimización”. Solo lo cachas midiendo dist/, que es exactamente la razón por la que el comando de arriba es todo el diagnóstico. Es el mismo género de problema que la trampa de Partytown que mató mi analítica en silencio (en inglés) — una suposición del cableado del theme que no se ve desde la página renderizada.
Una captura a esa resolución además está cerca del peor caso para PNG: degradados fotográficos, la interfaz del navegador, texto suavizado. La codificación sin pérdida de PNG paga por píxeles que nunca vas a apreciar al tamaño real de despliegue. La misma captura a 1600px de ancho, codificada como WebP en calidad 80, pesa 90KB — y las fechas, botones y cifras de la captura siguen siendo perfectamente legibles.
Modo de fallo #3: los 539KB que nadie pidió
hero-image.png venía con el theme. Nada en mi código lo referenciaba — ni una página, ni un componente. Se publicaba de todos modos, porque el manejo de assets del theme empaqueta lo que está en el directorio de imágenes, se use o no.
Este ni siquiera necesita Sharp. Borrar un archivo sin uso le gana a optimizarlo, siempre. Si construiste sobre una plantilla — AstroWind, un starter, lo que sea — revisa qué assets de demostración siguen viajando en tu build de producción. El mío llevaba meses enviando más de medio megabyte de relleno a cada visitante.
El arreglo: 15 líneas de Sharp, cero dependencias nuevas
Sharp ya estaba en mi package.json — el propio servicio de imágenes de Astro lo usa — así que el arreglo no cuesta ninguna dependencia nueva. Un script de un solo uso, ejecutado una vez con bun y borrado después. Es una migración, no herramienta de build:
import sharp from 'sharp';
// Foto de cámara de 3456×4608 → 900px: ~2× el ancho renderizado, margen para retina
await sharp('src/assets/images/me.jpg')
.rotate() // fija la orientación EXIF ANTES de redimensionar — ver nota abajo
.resize({ width: 900 })
.jpeg({ quality: 82, mozjpeg: true })
.toFile('src/assets/images/me-900.jpg');
// Captura PNG de 1898px → WebP de 1600px
await sharp('src/assets/images/ubuntu-cycling-camp-landingpage.png')
.resize({ width: 1600 })
.webp({ quality: 80 })
.toFile('src/assets/images/ubuntu-cycling-camp-landingpage.webp');Tres detalles que valen la tinta:
.rotate()sin argumentos no es opcional para fotos de celular. Sharp elimina los metadatos al escribir la salida, incluida la etiqueta EXIF de orientación. Si redimensionas sin.rotate(), la etiqueta que le decía a los navegadores “muestra esta imagen girada” desaparece — y tu foto aterriza de lado en la página..rotate()fija la orientación en los píxeles primero.- Sharp se niega a escribir sobre su archivo de entrada (
Cannot use same file for input and output). Escribe a un nombre nuevo y luego renombra sobre el original. mozjpeg: truecambia el codificador JPEG de Sharp a la configuración de mozjpeg — un archivo notablemente más pequeño con la misma calidad visual, gratis.
Después, una sola actualización de referencia: el componente que mostraba la captura apuntaba a .png, ahora apunta a .webp. Ese fue todo el diff de código.
Los resultados, verificados en el build
| Archivo | Antes | Después | Δ |
|---|---|---|---|
me.jpg | 934KB (3456×4608) | 86KB (900px, mozjpeg q82) | −91% |
ubuntu-cycling-camp-landingpage | 1.66MB (PNG sin procesar) | 90KB (WebP 1600px q80) | −95% |
hero-image.png | 539KB | eliminado | −100% |
Total de imágenes en dist/ | 3.37MB | 382KB | −88% |
Los números de “después” no son estimaciones — son la misma medición de find sobre dist/, repetida sobre el build publicado. El recurso LCP de la página de inicio pasó de 934KB a 86KB más variantes WebP bien elegidas; en una conexión 4G intermedia esa es la diferencia entre que el elemento de contenido más grande llegue en cientos de milisegundos o en varios segundos. JavaScript nunca fue el problema en este sitio. Las imágenes eran todo el problema.
Revisa los píxeles, no solo el conteo de bytes
Un paso en el que insisto: abre el archivo de antes y el de después, lado a lado, antes de hacer commit. Los bytes no te dicen si la calidad 80 embarró el texto de tu captura. En la mía hice zoom a la región más densa — fechas, etiquetas pequeñas de estadísticas, texto de botones — y confirmé que todo seguía legible. El avatar lo revisé en una pantalla retina, donde una fuente corta de tamaño se delata primero como bordes suaves.
Si el texto sí se hubiera embarrado, el arreglo es calidad 85 o un ancho mayor — la diferencia entre 90KB y 120KB no es nada cuando empezaste en 1.66MB. El presupuesto que estás protegiendo es el megabyte, no el último kilobyte.
La lista de verificación
- Mide
dist/, nosrc/— la salida del build es lo que descargan tus visitantes. El comando defind | sortes la auditoría completa. - Tu archivo fuente es el piso. Redimensiona las fuentes a ~2× su ancho renderizado más grande antes de que entren al repositorio. El pipeline optimiza hacia abajo desde tu fuente; nunca arregla la fuente en sí.
- Las capturas se vuelven WebP. Un WebP en calidad 80 al ancho de despliegue reemplaza un PNG de varios megabytes sin pérdida de legibilidad.
- Borra los assets de plantilla que no usas. La optimización más barata es un archivo eliminado.
- Revisa la salida con tus ojos al tamaño real antes de hacer commit.
- Repite la medición después de cada cambio de contenido — yo la integré a la validación que corre cada artículo antes de publicarse (en inglés).
Los frameworks se ganan su fama de rápidos, y Astro la merece — 15KB de JavaScript en todo un sitio es notable. Pero el pipeline solo optimiza lo que fluye a través de él, hacia abajo desde la fuente que le entregues. Dale un original de cámara y construirá fielmente variantes pequeñas mientras el original de 934KB se publica como el piso. El arreglo tomó 15 líneas y una tarde. La medición, un comando. Corre el comando.