Optimicé la página equivocada
Este artículo cuenta un error nuestro. Teníamos una página al borde de un umbral y una sospecha muy razonable sobre dónde sobraba peso. Medimos seis variantes: la sospecha era falsa, tres de ellas empeoraban el resultado y el peso que sobraba estaba en otra parte de la página.
El punto de partida
Una de nuestras páginas, la de FixTrack Pro, estaba a siete bytes de cruzar el umbral de la ventana de congestión inicial de TCP: los cerca de 14.600 bytes que caben en el primer viaje de ida y vuelta, y que ya explicamos en otro artículo. Ese valor es el habitual, diez segmentos de 1.460 bytes, pero depende de la configuración de cada servidor.
Medido contra producción, con curl:
| Medida | Bytes |
|---|---|
| Cuerpo comprimido | 13.997 |
| Cabeceras | 596 |
| Total | 14.593 |
| Margen | 7 |
Siete bytes no es un margen estrecho. No es un margen.
Next.js incrusta en el HTML las rutas de sus chunks de JavaScript, y esas rutas llevan un hash del contenido. Cada despliegue genera hashes distintos, que se comprimen distinto, y el documento entero se mueve unos bytes arriba o abajo sin que nadie haya tocado esa página.
Con siete bytes de margen, la página podía cruzar el umbral en un despliegue en el que no habíamos cambiado nada de ella. Había que recuperar espacio.
La hipótesis razonable
El encabezado de esa página muestra el logo del producto, y el logo cambia con
el tema del sistema. La implementación eran dos componentes <Image>, uno
visible en claro y otro en oscuro:
<Image src={logo} … className="block dark:hidden" />
<Image src={logoOscuro} … className="hidden dark:block" />
La sospecha parecía sólida. Cada <Image> de Next genera un srcset con
varias URL, cada una con su hash. Aquí había dos. Y en React Server Components
todo eso viaja dos veces: una en el HTML y otra en los datos de
hidratación.
Dos etiquetas pesadas, duplicadas. Ahí tenía que estar el gasto.
La medición que la desmontó
Antes de elegir una alternativa medimos el coste real del bloque. La forma honesta de hacerlo es quitarlo entero y ver cuánto baja el documento: no para dejarlo así, sino para conocer el techo de lo que se puede ahorrar.
Quitar el logo por completo ahorraba 196 bytes.
Ese era el techo. Cualquier alternativa que mantuviera el logo iba a ahorrar menos de 196, y necesitábamos más.
Y hubo algo peor. Medimos seis variantes, todas con Brotli en local:
| Alternativa | Brotli local | Diferencia |
|---|---|---|
Base: dos <Image> | 13.118 | — |
| Sin logo (solo de referencia) | 12.922 | −196, techo |
<picture> + getImageProps | 13.229 | +111, peor |
<picture> envolviendo <Image> | 13.196 | +78, peor |
<picture>, una URL por tema | 13.131 | +13, peor |
Dos <Image unoptimized> | 13.106 | −12, mejor |
<picture> con los PNG originales | 13.108 | −10, mejor |
Las tres variantes con <picture> y webp optimizado salían peor que el punto
de partida. La URL del <source> también viaja en los datos de hidratación,
así que añadir el elemento no sustituía nada: sumaba.
Y las dos únicas que ahorraban algo lo hacían sirviendo el PNG original del logo, de 52 o de 36 KB según el tema, en lugar del webp optimizado de 5 KB. Una imagen mucho más pesada para ahorrar diez o doce bytes de HTML. Eso no es una optimización: es un intercambio pésimo disfrazado de una.
El logo se quedó exactamente como estaba.
Por qué la intuición falló
La explicación es una propiedad de la compresión que conviene interiorizar:
Brotli no cobra por lo que se repite. Cobra por lo que es único.
Un compresor sustituye las secuencias repetidas por referencias a su aparición
anterior. La segunda etiqueta <Image> era casi idéntica a la primera, así
que, comprimida, costaba poco más que una referencia. La duplicación que
parecía cara era en realidad lo más barato de la página.
Razonar sobre el tamaño del HTML sin comprimir lleva a conclusiones al revés. Y el HTML sin comprimir no es lo que viaja por la red.
Dónde estaba el peso de verdad
La misma página muestra tres capturas de pantalla verticales, de móvil. En el diseño se ven a 240 píxeles de ancho, y llevaban este atributo:
sizes="240px"
Parece inofensivo y es lo contrario. sizes le dice a next/image que el
ancho mostrado depende del viewport, así que el componente no puede saber
cuál hará falta y genera el juego completo de anchos por si acaso:
quince, de 32 a 3840 píxeles.
Quince URL, cada una con su hash distinto. Por cada una de las tres capturas.
Eso sí es texto único, que es justo lo que un compresor no puede abaratar.
La corrección fue decirle el ancho al que se ven de verdad y quitar sizes.
Con un ancho fijo, next/image genera solo 1x y 2x.
| Brotli local | |
|---|---|
| Antes | 13.118 |
| Después | 12.879 |
| Ahorro | 239 bytes |
Más que borrar el logo entero, que era el techo de la hipótesis original.
El coste, que conviene decir: en pantallas 3x esas capturas se sirven ahora a 640 píxeles en lugar de 720, o sea a 2,67 veces su tamaño visible en vez de 3. En capturas de interfaz no se nota, pero es una pérdida real de resolución y la aceptamos a sabiendas.
El resultado, medido donde cuenta
Las cifras anteriores son de Brotli en local. Lo que importa es producción, y
para pasar de una a otra usamos un factor de calibración: en esta página
concreta, Vercel sirve un 6,7 % más que brotli -q 4 en local. Ese factor
es el de esta página con nuestro proveedor, no una constante: en otra página o
en otro servidor sale distinto.
Con ese factor, la estimación antes de desplegar era de 14.335 bytes.
Medido con curl contra producción, después del despliegue:
| Medida | Bytes |
|---|---|
| Cuerpo comprimido | 13.711 |
| Cabeceras | 596 |
| Total | 14.307 |
| Margen | 293 |
Estimado 14.335, real 14.307. Un error de 28 bytes.
El margen pasó de 7 a 293 bytes: cuarenta y dos veces más, muy por encima del ruido que generan los hashes en cada despliegue. La página dejó de necesitar vigilancia, que era el objetivo real.
Qué nos llevamos
Mide el techo antes de elegir la solución. Quitar el bloque sospechoso por completo, aunque no vayas a dejarlo así, te dice en un minuto si merece la pena seguir por ahí. Nos habría ahorrado comparar seis variantes de algo que no podía funcionar.
Comprueba que tu optimización optimiza. Tres de las seis alternativas que probamos empeoraban el resultado. Todas parecían mejores sobre el papel.
Y la que de verdad cuesta aprender:
En un documento comprimido, el gasto no está donde hay más texto. Está donde hay más texto distinto.
Una etiqueta duplicada es casi gratis. Quince URL con quince hashes, no.