De 0.75 a 0.001 de CLS: el salto que no veía en mi máquina
Mi portafolio tenía un CLS de 0.75 en la mitad de sus páginas y yo nunca lo vi. Qué lo causaba, cómo lo arreglé con una línea de CSS y qué más cambié para pasar de 60 a 90 en Lighthouse mobile.
3 min de lectura
Cuando rediseñé este portafolio corrí Lighthouse antes de tocar nada, para tener contra qué comparar. En la home, mobile daba 60 de performance. Eso lo esperaba. Lo que no esperaba apareció después, al medir las páginas internas: /projects y cada caso de estudio tenían un CLS de 0.75.
Para tener una referencia: Google considera “bueno” un CLS por debajo de 0.1. Con 0.75, la página entera se estaba moviendo delante de quien la visitaba. Y yo nunca lo había visto.
Por qué no lo veía
El sitio es una SPA en Vue con las rutas cargadas de forma diferida: cada página es un chunk de JavaScript que se descarga recién cuando entrás a ella.
{
path: "/projects",
name: "projects",
component: () => import("../views/ProjectsView.vue"),
}En mi computadora, con el chunk en caché y buena conexión, la ruta aparece casi al instante. En un celular con 4G lento, que es lo que simula Lighthouse, pasan unos cientos de milisegundos entre que arranca la app y llega el chunk. Y en ese rato el layout era este:
- El
<header>se pinta arriba. <main>está vacío: todavía no hay nada que renderizar adentro.- El
<footer>queda pegado al header, en la parte de arriba de la pantalla. - Llega el chunk, se renderiza la página y el footer sale disparado hacia abajo.
Ese último paso es el salto. El footer es grande (tiene un wordmark que ocupa todo el ancho), así que mueve mucha superficie de pantalla, y por eso el número daba tan alto.
El arreglo: reservar el espacio
La solución fue una línea: darle a <main> una altura mínima de una pantalla, para que el footer arranque abajo aunque la ruta todavía no haya llegado.
<!-- min-h: mientras carga el chunk de la ruta, <main> está vacío; sin
altura mínima el footer aparece arriba y después salta (CLS). -->
<main id="main" tabindex="-1" class="min-h-[100svh] pt-20">
<RouterView />
</main>Usé 100svh y no 100vh a propósito: en mobile, vh cuenta la pantalla con la barra del navegador escondida, y svh la cuenta con la barra visible. Con svh la altura reservada nunca es más grande que lo que realmente se ve.
Con eso, el CLS de /projects y de los casos bajó de 0.75 a 0.001.
Lo que aprendí del error
Lo más útil no fue el arreglo, sino entender por qué se me había escapado:
- Mi entorno escondía el problema. El salto existe solo mientras el chunk está en camino. Con caché y fibra, ese momento dura tan poco que no se nota.
- Medí solo la home. La home se carga con el bundle principal, así que no tenía el problema. Las rutas diferidas sí. Desde entonces corro Lighthouse en una página de cada tipo, no solo en la entrada.
- Cargar de forma diferida tiene un costo de layout. Partir el JavaScript en chunks está bien, pero el espacio de lo que todavía no llegó hay que reservarlo. Pasa lo mismo con las imágenes sin
widthyheight.
El resto de la pasada de performance
El CLS fue lo más llamativo, pero no lo único. En la misma tanda:
- Variantes responsivas de las capturas. Cada imagen de proyecto tiene versiones WebP de 800 y 1600 px, servidas con
srcsetysizes. El original queda solo para el lightbox. La home pasó de 755 KB a 376 KB. - Un LCP más rápido en el hero. El rol y el párrafo principal tenían una animación de entrada que los arrancaba en opacidad cero. Como son el elemento más grande de la pantalla en mobile, Lighthouse los toma como LCP, y la animación lo retrasaba. Ahora se pintan sin animación.
- JavaScript inicial en pedazos cacheables. El bundle inicial pasó de 695 KB gzip en un solo archivo a unos 159 KB gzip en cuatro chunks. Vue, GSAP y los íconos van en chunks separados, que casi no cambian entre deploys. three.js, que solo usa la demo 3D de un caso, se descarga recién cuando te acercás a ella.
Los números
Lighthouse 12, build de producción, mobile con el throttling por defecto:
| Página | Perf. mobile | Perf. desktop | Accesibilidad | CLS |
|---|---|---|---|---|
| Home (antes) | 60 | 93 | 80 | — |
| Home (después) | 90 | 100 | 100 | — |
/projects (después) |
88 | 99 | 100 | 0.001 |
| Caso Wink App (después) | 85 | 99 | 100 | 0.001 |
Todavía queda trabajo: el LCP mobile sigue entre 3.2 y 3.8 segundos, frenado sobre todo por las fuentes de Google Fonts y porque el contenido se arma en el navegador. Las siguientes ideas son servir las fuentes desde el propio sitio y prerenderizar las rutas. Cuando lo haga, lo cuento acá.
¿Te sirvió? Hablemos de tu proyecto.
Si estás construyendo algo parecido y querés una mano con el diseño o el frontend, escribime.
Hablemos