wandres.dev
OPTIMIZAR CLS · El layout que no salta

De dónde sale tu CLS: atribuir cada décima a un elemento

El procedimiento completo para pasar de un número agregado a una lista ordenada de elementos culpables, con el observador en crudo, la atribución de campo, y las dos herramientas del navegador que enseñan el salto.

⏱ 19 min

El CLS es la única de las tres métricas principales cuyo culpable es siempre un elemento concreto del documento. No hay ambigüedad, no hay atribución estadística, no hay que adivinar: el navegador sabe exactamente qué rectángulo estaba en una posición y pasó a estar en otra, y lo expone. Que la mayoría de los equipos siga tratando el CLS como un número misterioso es un problema de procedimiento, no de instrumentación, porque la instrumentación cabe en veinte líneas.

🎯 Al terminar esta lección sabrás
  • Extraer la lista de elementos culpables ordenada por puntuación acumulada.
  • Distinguir el CLS de carga del CLS tardío usando el instante de cada desplazamiento.
  • Reproducir un desplazamiento en el navegador con las dos herramientas visuales.
  • Montar el bucle de trabajo que va del número agregado al cambio concreto.

Los datos que ya tienes

Un desplazamiento de layout se puntúa multiplicando la fracción de la ventana afectada por la fracción de la ventana recorrida, y el CLS de la página es la mayor ráfaga acumulada en una ventana de sesión, no la suma total. El umbral es 0,1 en el percentil 75 para considerarse bueno y 0,25 para considerarse malo. Esas son las reglas del juego; lo que sigue es cómo se aplica a tu página.

La entrada layout-shift trae mucho más que su puntuación. Cada una lleva un array sources con los elementos que se movieron, y de cada uno el rectángulo anterior y el actual. Con eso se construye directamente la lista de culpables:

function rutaCorta(nodo) {
  if (!nodo || nodo.nodeType !== 1) return '(nodo sin identificar)';
  const partes = [];
  for (let n = nodo; n && n.nodeType === 1 && partes.length < 4; n = n.parentElement) {
    let s = n.tagName.toLowerCase();
    if (n.id) { partes.unshift(`${s}#${n.id}`); break; }
    if (n.classList.length) s += `.${[...n.classList].slice(0, 2).join('.')}`;
    partes.unshift(s);
  }
  return partes.join(' > ');
}

const culpables = new Map();

new PerformanceObserver((lista) => {
  for (const e of lista.getEntries()) {
    if (e.hadRecentInput) continue;
    for (const s of e.sources) {
      const clave = rutaCorta(s.node);
      const acc = culpables.get(clave) ?? { puntos: 0, veces: 0, primerMs: Infinity, ultimoMs: 0, dy: 0 };
      acc.puntos += e.value;
      acc.veces += 1;
      acc.primerMs = Math.min(acc.primerMs, Math.round(e.startTime));
      acc.ultimoMs = Math.max(acc.ultimoMs, Math.round(e.startTime));
      acc.dy = Math.max(acc.dy, Math.abs(s.currentRect.top - s.previousRect.top));
      culpables.set(clave, acc);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  console.table(
    [...culpables]
      .map(([elemento, v]) => ({ elemento, ...v }))
      .sort((a, b) => b.puntos - a.puntos),
  );
});

El buffered: true es imprescindible: sin él pierdes todos los desplazamientos ocurridos antes de que tu script se ejecutara, que suelen ser los peores. Y el volcado en visibilitychange en lugar de en unload es lo único fiable en móvil, donde la página puede morir sin pasar por la descarga.

Una advertencia sobre la aritmética de esta tabla: la columna de puntos no suma el CLS de la página. Está atribuyendo la puntuación completa de cada desplazamiento a cada uno de sus elementos de origen, y un desplazamiento con tres orígenes cuenta tres veces. Es una heurística de ordenación, no una descomposición exacta. Sirve perfectamente para lo que hace falta —saber a quién mirar primero— y no sirve para presentar porcentajes en un informe. Chrome además limita el array sources a los pocos elementos de mayor impacto, así que en un desplazamiento masivo verás una muestra, no el censo.

La columna dy es la que convierte el número en algo accionable: dice cuántos píxeles se movió el elemento en el peor caso. Un dy de 4 píxeles es una fuente que ha cambiado de métrica; uno de 250, un bloque de contenido que ha aparecido de la nada.

Carga o tardío: la pregunta que decide todo

Las dos columnas de instante, primerMs y ultimoMs, separan dos problemas que se arreglan de formas completamente distintas.

Si toda tu masa está por debajo de los 2.500 milisegundos, tienes un problema de carga: espacio no reservado en imágenes, incrustaciones o bloques de anuncios, y el intercambio de fuente. Se arregla con declaraciones estáticas y lo verifica cualquier herramienta de laboratorio.

Si hay una segunda joroba entre los 4 y los 20 segundos, tienes un problema de contenido inyectado, y ninguna herramienta que cargue la página y se marche lo va a ver nunca. El aviso de consentimiento que aparece tarde, el anuncio que se recarga con otra altura, la barra de notificación empujada por un evento del servidor, el bloque de recomendaciones que llega cuando el usuario ya está leyendo.

La distinción importa tanto que conviene medirla de forma agregada en campo, no solo en una sesión. La librería oficial de métricas web trae el desglose ya hecho en su versión con atribución:

import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, rating, attribution: a }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metrica: 'CLS',
    valor: value,
    calificacion: rating,
    elemento: a.largestShiftTarget,      // selector del elemento del peor desplazamiento
    peorValor: a.largestShiftValue,      // puntuacion de ese desplazamiento
    instante: Math.round(a.largestShiftTime),
    estadoCarga: a.loadState,            // en que fase de la carga ocurrio
  }));
});

Con eso en producción durante una semana, el histograma de instante responde la pregunta de arriba para el conjunto de tus usuarios, y el recuento por elemento te da la lista de trabajo ordenada por impacto real. Es la diferencia entre optimizar lo que te salta a la vista en tu portátil y optimizar lo que le pasa a la gente.

loadState merece una mención aparte porque es la columna que menos se usa y la que más rápido orienta: distingue si el desplazamiento ocurrió mientras el documento aún cargaba, tras el evento de contenido cargado, o con la página ya completa. Un desplazamiento con la página completa nunca es culpa de una imagen sin dimensiones.

Verlo con los ojos

Los números dicen quién; para entender el porqué hay que ver el salto. Dos herramientas del navegador, y las dos tardan diez segundos en activarse.

La superposición de regiones de desplazamiento. En el panel de renderizado de las herramientas de desarrollo, la casilla que marca las regiones de desplazamiento pinta un rectángulo azul translúcido sobre cualquier zona que se haya movido, en el momento en que se mueve. Es la forma más rápida de cazar desplazamientos tardíos: dejas la casilla marcada, usas la página con normalidad, y los destellos azules te dicen dónde mirar sin necesidad de instrumentar nada.

La pista de desplazamientos del panel de rendimiento. Al grabar un perfil, los desplazamientos aparecen como marcas en su propia pista, agrupados por ráfaga. Al pinchar uno se ve su puntuación, los elementos implicados y la captura de pantalla del fotograma anterior y del posterior. Esa pareja de capturas es lo que hace evidente el porqué en los casos que los números no explican.

Y una técnica manual que vale por las dos juntas cuando el problema es de carga: bloquear los recursos y recargar. En el panel de red se pueden bloquear patrones de petición; si bloqueas todas las imágenes y la página conserva su maquetación, el espacio estaba reservado. Si se desmonta, ya sabes que el CLS estaba esperando a una conexión lenta para aparecer. Es la prueba directa de la reserva de espacio y no requiere ninguna medición.

Tu CLS de laboratorio es sistemáticamente mejor que el real, y la causa es que el laboratorio no hace scroll

La discrepancia entre el CLS de una auditoría y el CLS de campo es la mayor de las tres métricas principales, y casi siempre en la misma dirección: el laboratorio dice 0,02 y el campo dice 0,18. Hay tres causas y ninguna es un error de medición.

La primera: la auditoría no se desplaza. Carga la página, espera a que se calme, mide y se va. Todo el contenido diferido que hay por debajo del pliegue —imágenes con carga perezosa, incrustaciones, bloques de anuncios, scroll infinito— nunca llega a cargarse, y por tanto nunca desplaza nada. El usuario real hace scroll a los dos segundos y dispara la mitad de tu CLS de golpe.

Y aquí está el detalle cruel que casi nadie conoce: el desplazamiento de página no cuenta como entrada del usuario a efectos de la exclusión. La marca hadRecentInput solo se activa con entradas discretas: toque, clic, tecla. Un salto que ocurra mientras el usuario hace scroll suma íntegro. La intuición de que “como estaba haciendo scroll no cuenta” es exactamente al revés.

La segunda: la auditoría mide una ventana de tiempo, el campo mide la sesión entera. Los desplazamientos tardíos, los que ocurren a los diez o veinte segundos, están fuera de la ventana de cualquier herramienta sintética por construcción.

La tercera: la auditoría carga con una ventana concreta y una red concreta. El CLS depende del tamaño de la ventana, porque la fracción de distancia se divide por su dimensión mayor y la fracción de impacto por su área. Un desplazamiento de 80 píxeles puntúa el doble en una ventana de móvil de 700 píxeles de alto que en un escritorio de 1.400. Y depende de la velocidad de la red, porque en una conexión rápida la imagen puede llegar antes del primer pintado y no desplazar nada en absoluto.

La consecuencia de método: el CLS es la métrica donde el campo manda con más diferencia, y la única forma sensata de trabajarla en laboratorio es reproducir las tres condiciones a mano. Es decir: emular un móvil real, estrangular la red a algo lento de verdad, y hacer scroll hasta el final de la página mientras el observador está activo. Ese último paso, que no automatiza ninguna herramienta estándar, suele multiplicar por cinco el número que ves.

Un guion de consola que hace el recorrido y deja el resultado:

async function recorrerYMedir() {
  const alto = innerHeight;
  const total = document.documentElement.scrollHeight;
  for (let y = 0; y < total; y += alto * 0.8) {
    scrollTo(0, y);
    await new Promise((r) => setTimeout(r, 900));   // dejar cargar lo diferido
  }
  scrollTo(0, 0);
  await new Promise((r) => setTimeout(r, 900));
}

Ejecútalo con la tabla de culpables activa. El resultado se parece mucho más a lo que ven tus usuarios que cualquier puntuación de auditoría, y aparecerán elementos que en la carga inicial no existían.

El bucle de trabajo

El procedimiento completo, en el orden que minimiza el tiempo perdido.

Uno: saca la lista de campo. Sin datos reales estás optimizando tu propia sesión, que no es representativa de nada. Una semana de recogida basta para ordenar.

Dos: coge el elemento con más puntuación acumulada, no el desplazamiento más grande. Un salto enorme que le pasa al 1% de los usuarios importa menos que uno mediano que le pasa a todos.

Tres: reprodúcelo en local con la ventana, la red y el recorrido de scroll del guion anterior, y confirma que lo ves. Si no consigues reproducirlo, el arreglo será a ciegas.

Cuatro: arréglalo y verifica con la tabla, comprobando que el elemento desaparece de las primeras posiciones y que no ha subido otro en su lugar. Reservar espacio en un sitio a veces mueve el problema al de al lado.

Cinco: vuelve al campo. El único cierre válido del ciclo es ver bajar el percentil 75 real.

⚔️ Reto práctico

Instala la tabla de culpables en tu página, ejecuta el guion de recorrido, y anota las tres primeras filas con su dy y su instante. Después repite exactamente lo mismo con la ventana en tamaño de móvil y la red estrangulada. Compara las dos listas: si el orden cambia, tu prioridad de trabajo estaba mal establecida.