wandres.dev
RENDERIZADO II · Layout thrashing y el DOM

Detectar el thrashing: del panel de rendimiento a la producción

Dónde mira exactamente un perfil para encontrar layouts forzados, qué avisa la consola y qué no, y cómo instrumentarlo en producción con Long Animation Frames.

⏱ 18 min

El layout thrashing es invisible en el código y casi invisible en el perfil si no sabes qué buscar: no produce una función lenta, produce muchas barras cortas. La buena noticia es que el navegador lo sabe todo sobre él y lo cuenta por tres canales distintos, cada uno con su alcance y sus puntos ciegos. Esta lección recorre los tres y termina con un detector de cuarenta líneas que puedes dejar activo en desarrollo.

🎯 Al terminar esta lección sabrás
  • Localizar un layout forzado en el panel de rendimiento y leer su pila de invalidación.
  • Interpretar el aviso de la consola y saber por qué no aparece casi nunca.
  • Instrumentar el coste de layout forzado en producción con la API de Long Animation Frames.
  • Montar un detector de desarrollo que avise en el momento exacto en que se rompe el patrón.

En el panel de rendimiento: dónde mirar exactamente

Graba una interacción, abre la pista del hilo principal y busca dentro de una tarea larga la alternancia característica: un bloque de JavaScript, una barra de Layout, otro bloque, otra barra, decenas de veces. Cuando el motor tiene que resolver el layout dentro de tu script, esa barra aparece anidada bajo tu función, no al final de la tarea. Ese anidamiento es la firma. Un layout normal, el que hace el navegador al terminar, cuelga de la fase de renderizado y no de tu código.

Chrome marca esas barras con un triángulo de aviso en la esquina. Al seleccionarlas, el resumen te da tres datos que valen su peso en oro:

  • La pila de la primera invalidación. Te dice qué línea ensució el layout, que casi nunca es la misma que lo forzó. Este es el dato que no se puede obtener de ninguna otra manera y el que convierte una hora de búsqueda en dos minutos.
  • El ámbito del layout, parcial o de documento completo. Un layout de documento completo dentro de un bucle es la peor variante posible; uno parcial indica que el motor consiguió acotar el trabajo a un subárbol.
  • El tamaño del árbol de layout, en número de nodos procesados. Si ves cuatro mil nodos por iteración, ya tienes la multiplicación hecha.

Cuando la alternancia es demasiado fina para verse, cambia a la vista de abajo arriba y ordena por tiempo propio: si Layout y Recalculate Style aparecen arriba con un número alto y la actividad está repartida entre cientos de invocaciones, tienes thrashing aunque ninguna barra individual llame la atención.

Un truco que ahorra mucho: filtra la grabación a una sola interacción y compara el número de eventos de layout con el número de fotogramas. Un fotograma sano tiene uno. Si tienes trescientos eventos de layout y veinte fotogramas, el diagnóstico está hecho antes de mirar una sola pila.

El aviso de la consola y por qué no lo has visto nunca

Chrome emite un mensaje del tipo [Violation] Forced reflow while executing JavaScript took NNms cuando el coste de un layout forzado supera un umbral interno. Es una información excelente y la mayoría de la gente no la ha visto jamás, por dos razones.

La primera es que las violations de Blink se emiten en el nivel Verbose de la consola, que está desactivado por defecto. Hay que abrir el filtro de niveles del panel de consola y marcarlo explícitamente. En cuanto lo haces, muchas aplicaciones empiezan a escupir estos avisos de forma constante.

La segunda es el umbral. El aviso solo salta cuando un único layout forzado es caro por sí solo, del orden de varias decenas de milisegundos. Y ese no es el caso típico del thrashing: el thrashing hace quinientos layouts de dos milisegundos, que suman un segundo y no disparan ningún aviso porque ninguno individual pasa el listón. Es decir, la consola avisa del caso menos frecuente y calla en el más frecuente. Sirve para confirmar, no para descubrir.

En producción: Long Animation Frames

La herramienta que faltó durante años es la API de Long Animation Frames, disponible en Chromium. Un long-animation-frame es un fotograma cuyo trabajo de renderizado tardó más de 50 ms, y su entrada trae el desglose por script. Cada script del array incluye forcedStyleAndLayoutDuration: exactamente los milisegundos que ese script gastó forzando estilo y layout de forma síncrona. Es el dato que buscas, atribuido a un fichero, una función y una posición en el código.

// Deteccion de layout forzado en usuarios reales.
if (typeof PerformanceObserver !== 'undefined' &&
    PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {

  const observador = new PerformanceObserver((lista) => {
    for (const fotograma of lista.getEntries()) {
      for (const script of fotograma.scripts) {
        if (script.forcedStyleAndLayoutDuration < 5) continue;
        enviarMetrica({
          tipo: 'layout-forzado',
          ms: Math.round(script.forcedStyleAndLayoutDuration),
          fuente: script.sourceURL,
          funcion: script.sourceFunctionName,
          posicion: script.sourceCharPosition,
          disparador: script.invoker,       // p. ej. BUTTON#comprar.onclick
          tipoDisparador: script.invokerType, // p. ej. user-callback
          fotogramaMs: Math.round(fotograma.duration),
          bloqueoMs: Math.round(fotograma.blockingDuration),
        });
      }
    }
  });

  observador.observe({ type: 'long-animation-frame', buffered: true });
}

function enviarMetrica(datos) {
  navigator.sendBeacon('/rum', JSON.stringify(datos));
}

invoker es el campo que convierte esto en accionable: te dice qué provocó la ejecución —un manejador de clic sobre un botón concreto, un callback de IntersectionObserver, la resolución de una promesa—, así que el informe deja de ser “hay layout forzado en el bundle” y pasa a ser “el botón de comprar gasta 40 ms forzando layout en colocarBadge”.

El punto ciego hay que decirlo: LoAF solo emite entradas para fotogramas de más de 50 ms. Un thrashing que cueste 30 ms por fotograma degrada la fluidez de forma perfectamente perceptible y no genera ni una sola entrada. Para eso el complemento es agregar forcedStyleAndLayoutDuration como métrica de percentil: si el p75 de tus usuarios tiene cero, estás bien; si tiene quince milisegundos, tienes un problema estructural aunque nunca cruces los cincuenta.

El número que hay que vigilar no es el total, es el número de eventos de layout por fotograma

Casi todos los paneles de rendimiento —el del navegador y los de las herramientas comerciales— presentan el layout como un total: “300 ms en Layout”. Ese número, solo, no distingue entre dos situaciones que exigen soluciones opuestas. Trescientos milisegundos repartidos en un layout significan que tu árbol es demasiado grande o demasiado complejo, y la solución es de arquitectura del DOM: menos nodos, contain, virtualización, sacar tablas de table-layout: auto. Trescientos milisegundos repartidos en doscientos layouts significan que tu árbol está bien y tu código está mal ordenado, y la solución es el patrón de dos fases. Son diagnósticos distintos, remedios distintos y equipos distintos, y el total no te dice cuál tienes. Por eso la métrica que de verdad hay que instrumentar no es el tiempo de layout sino la razón entre eventos de layout y fotogramas. Un fotograma sano tiene un layout, o cero si nada cambió. En cuanto esa razón pasa de uno, cualquier optimización del layout individual es tiempo perdido: podrías reducir el coste de cada layout a la mitad y seguirías haciendo doscientos. Este es el error de diagnóstico más caro que he visto repetirse: equipos que pasan semanas simplificando el DOM y aplicando contain para arreglar un perfil que en realidad decía que un manejador de scroll leía getBoundingClientRect ochenta veces por segundo. Cuando abras un perfil con mucho layout, cuenta antes de optimizar. El histograma manda sobre la suma.

Un detector para desarrollo

El panel te dice que hay thrashing después de grabar. La consola avisa tarde. Lo que de verdad cambia el día a día es un aviso en el instante exacto en que el patrón se rompe, con la pila de llamadas. Se consigue envolviendo los accesores del prototipo: contar escrituras, y avisar cuando llega una lectura con escrituras acumuladas.

// detector-thrashing.js — cargar SOLO en desarrollo.
export function detectarThrashing({ umbral = 3, maxAvisos = 30 } = {}) {
  let escrituras = 0;
  let avisos = 0;

  function comprobar(que) {
    if (escrituras >= umbral && avisos < maxAvisos) {
      avisos++;
      console.warn(
        'Layout forzado: lectura de ' + que + ' tras ' + escrituras + ' escrituras'
      );
      console.trace();
    }
    escrituras = 0;
  }

  const lecturas = [
    [HTMLElement.prototype, ['offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft']],
    [Element.prototype, ['clientWidth', 'clientHeight', 'scrollWidth', 'scrollHeight',
                         'scrollTop', 'scrollLeft']],
  ];

  for (const [proto, props] of lecturas) {
    for (const prop of props) {
      const desc = Object.getOwnPropertyDescriptor(proto, prop);
      if (!desc || typeof desc.get !== 'function') continue;
      const getOriginal = desc.get;
      Object.defineProperty(proto, prop, {
        configurable: true,
        enumerable: desc.enumerable,
        set: desc.set,
        get() {
          comprobar(prop);
          return getOriginal.call(this);
        },
      });
    }
  }

  const rectOriginal = Element.prototype.getBoundingClientRect;
  Element.prototype.getBoundingClientRect = function () {
    comprobar('getBoundingClientRect');
    return rectOriginal.call(this);
  };

  // Escrituras: atributos, setProperty y los accesores con nombre de style.
  const setAttrOriginal = Element.prototype.setAttribute;
  Element.prototype.setAttribute = function (...args) {
    escrituras++;
    return setAttrOriginal.apply(this, args);
  };

  const setPropOriginal = CSSStyleDeclaration.prototype.setProperty;
  CSSStyleDeclaration.prototype.setProperty = function (...args) {
    escrituras++;
    return setPropOriginal.apply(this, args);
  };

  const nombradas = ['width', 'height', 'top', 'left', 'right', 'bottom',
                     'transform', 'display', 'margin', 'padding', 'cssText'];
  for (const prop of nombradas) {
    const desc = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, prop);
    if (!desc || typeof desc.set !== 'function') continue;
    const setOriginal = desc.set;
    Object.defineProperty(CSSStyleDeclaration.prototype, prop, {
      configurable: true,
      enumerable: desc.enumerable,
      get: desc.get,
      set(valor) {
        escrituras++;
        return setOriginal.call(this, valor);
      },
    });
  }
}

Tres advertencias honestas sobre esto. No cubre toda la lista de APIs que fuerzan layout, solo las frecuentes; ampliarlo es copiar líneas. Cuenta escrituras que a lo mejor no invalidaron nada —asignar el mismo valor que ya había no ensucia—, así que produce algún falso positivo. Y ralentiza la página, porque cada acceso a offsetWidth pasa ahora por una función de JavaScript: nunca en producción.

A cambio, la primera vez que lo activas sobre una aplicación mediana suele señalar entre tres y diez sitios que llevaban años ahí. Con la pila de llamadas incluida, arreglarlos es trabajo de tarde. El detalle de qué APIs añadir está en la lista completa, y el patrón con el que se arreglan, en leer todo y luego escribir todo.

⚔️ Instrumenta tu aplicación
  1. Activa el nivel Verbose de la consola en una aplicación real durante cinco minutos de uso y cuenta cuántas violaciones de forced reflow aparecen.
  2. Añade el observador de Long Animation Frames y registra forcedStyleAndLayoutDuration agregado por sourceFunctionName. Ordena por total.
  3. Carga el detector de desarrollo y arregla el primer aviso que salga. Vuelve a medir.
  4. En un perfil grabado, localiza un evento de layout forzado y anota su ámbito y su tamaño de árbol. Calcula el coste total multiplicando por el número de iteraciones.
  5. Compara el número de eventos de layout con el número de fotogramas durante un scroll. Si la razón supera uno, tienes deberes.