wandres.dev
EL HILO DEL COMPOSITOR · Qué se anima fuera del hilo principal

Lo que descalifica una animación sin avisar

Los ocho casos donde una animación aparentemente compuesta cae al hilo principal sin ningún error ni advertencia, y cómo detectarlos antes de producción.

⏱ 19 min

No hay ninguna advertencia. No hay un error de consola, ni una excepción, ni un aviso en el panel de estilos. Una animación que cumplía todas las condiciones deja de componerse porque alguien añadió una propiedad tres semanas después, y el único síntoma es que en un móvil de gama media va a tirones. Esta lección recoge los casos que descalifican en silencio, ordenados por la frecuencia con la que aparecen en código real, y con la forma de detectar cada uno.

🎯 Al terminar esta lección sabrás
  • Reconocer los ocho patrones que degradan una animación compuesta sin señal de error.
  • Explicar por qué un cambio en un elemento distinto puede descalificar tu animación.
  • Detectar la degradación de forma sistemática y no por casualidad.
  • Escribir código resistente a que un tercero rompa la promoción.

Los ocho casos

Uno: una propiedad no componible se coló en los mismos fotogramas. El caso más frecuente con diferencia, y ya lo vimos en la lección de condiciones. Merece repetirse aquí porque su forma habitual de aparecer es evolutiva: alguien añade height o box-shadow a un @keyframes que llevaba meses funcionando. El código nuevo funciona; lo que se rompe es el rendimiento del código viejo.

Dos: el contenido de la capa cambia durante la animación. Una capa sirve porque su textura se rasteriza una vez. Si dentro del elemento hay un contador que se actualiza, un vídeo, un canvas que se redibuja o texto que cambia, la textura se invalida en cada fotograma y la promoción no ahorra nada; el motor puede decidir no promover. El síntoma clásico es una tarjeta que se desliza suave hasta que alguien le añade un contador de tiempo dentro.

Tres: will-change se retiró antes de tiempo, o nunca se puso y la capa no llegó a crearse. Cuando el motor promueve por su cuenta al detectar la animación, lo hace en el primer fotograma. En ese primer fotograma hay que crear la capa y rasterizar su contenido, lo que puede costar varios milisegundos y producir un tirón inicial. No es una descalificación completa, pero es la degradación más visible y la más fácil de arreglar.

Cuatro: la promoción implícita cambió el orden. Alguien promovió un elemento que está por debajo del tuyo en el orden de pintado. Ahora el tuyo también necesita capa, y con él todos los que se solapen. La memoria se dispara, el navegador descarta baldosas y aparecen parpadeos. Nada de esto ocurre en tu código.

Cinco: se agotó el presupuesto de memoria de textura. El motor tiene un límite y, al alcanzarlo, deja de promover aunque se lo pidas. will-change se convierte entonces en una petición ignorada. Esto es dependiente del dispositivo, así que se manifiesta solo en máquinas modestas, que es donde menos se prueba.

Seis: el elemento entró en un contexto que impide la composición independiente. Un ancestro con mix-blend-mode, un filter en un ancestro que obliga a componer el subárbol como una unidad, o una máscara que exige leer el resultado. Un cambio de estilo en un contenedor lejano puede quitarte la promoción sin que tu regla haya cambiado.

Siete: el tamaño de la caja cambia mientras la animación usa porcentajes. El precálculo de los valores absolutos deja de ser válido. Ocurre en componentes responsivos que se animan mientras un contenedor se está redimensionando, y por eso es intermitente: depende de si el redimensionado coincide con la animación.

Ocho: no era una animación, era un bucle. Escribir transform en cada callback de requestAnimationFrame produce el mismo resultado visual y ninguna posibilidad de composición. Ocurre a menudo cuando alguien “mejora” una animación declarativa para poder controlarla desde JavaScript.

Por qué no hay aviso

Merece la pena entender la razón de que el motor no te diga nada, porque explica que esto no vaya a cambiar.

La promoción al compositor es una optimización, y las optimizaciones no forman parte del contrato de la plataforma. El resultado visual de una animación compuesta y una no compuesta es idéntico, y la especificación no obliga a componer nada. Si el motor emitiera un aviso cada vez que decide no optimizar, estaría exponiendo detalles de implementación que cambian entre versiones y entre motores, y el código empezaría a depender de ellos.

La consecuencia práctica es que la detección tiene que ser tuya y tiene que ser sistemática. Ninguna herramienta te va a avisar de una regresión de composición como te avisa de un error de sintaxis.

La descalificación más cara es la que se hereda del contenedor, y es la que nunca vas a buscar

De los ocho casos, siete están en tu componente y los encuentras leyendo tu código. El octavo no, y es el que produce las sesiones de depuración más largas: un antepasado puede descalificar tu animación sin tocar tu elemento. Un filter: drop-shadow() que alguien pone en un contenedor tres niveles por encima obliga a componer todo el subárbol como una unidad, porque el filtro se aplica al resultado conjunto: tu capa deja de ser independiente y su transformación ya no se puede resolver sin volver a componer el grupo entero. Lo mismo hace un mix-blend-mode en un ancestro, una máscara, y en algunos casos un overflow: hidden con esquinas redondeadas, que exige recortar con una forma no rectangular. El detalle que lo convierte en pesadilla es que el cambio y el síntoma están en ficheros distintos, escritos por personas distintas, con semanas de diferencia: tu componente iba suave, alguien rediseñó la tarjeta que lo contiene, y ahora tu componente va a tirones sin que su código haya cambiado ni una línea. Hay dos defensas y conviene aplicar las dos. La primera es de diagnóstico: cuando algo que iba bien deja de ir bien, sube por el árbol antes de mirar hacia abajo, y comprueba en el panel de capas si el elemento sigue teniendo la suya. La segunda es de arquitectura: si un componente tiene una animación crítica, sácalo del subárbol problemático. Un elemento en la capa superior del documento —a través de popover o de dialog, o simplemente montado en otro punto del árbol— no hereda los efectos de composición de donde estaba lógicamente. Es la única forma de que una decisión de estilo ajena no pueda romperte el rendimiento.

Detección sistemática

Tres mecanismos, de menos a más esfuerzo, y conviene tener los tres.

El bloqueo del hilo principal como prueba binaria. Es la única prueba que da una respuesta sin ambigüedad. Si la animación sigue durante el bloqueo, está compuesta; si no, no lo está. Merece la pena dejarlo como utilidad accesible en desarrollo:

// Utilidad de desarrollo: bloquea el hilo principal a demanda.
globalThis.bloquear = (ms = 1500) => {
  const fin = performance.now() + ms;
  while (performance.now() < fin) { /* ocupado */ }
};

El destello de repintado como señal continua. Con la opción activada en el panel de renderizado, una animación compuesta no destella. Si tu animación pinta un rectángulo verde en cada fotograma, no está compuesta, sin más análisis. Es la prueba que más rápido descarta el caso dos, el del contenido que cambia.

El panel de rendimiento para la causa concreta. En Chromium, la pista de animaciones marca con un triángulo rojo las que no se han podido componer, y al seleccionarlas el resumen indica el motivo. Ese motivo es el atajo directo al caso concreto de la lista de arriba, y ahorra el trabajo de deducirlo.

Para regresiones, lo que funciona es una prueba automatizada que no mida tiempos —que son ruidosos— sino la cuenta de fotogramas largos durante una interacción concreta. Con PerformanceObserver sobre los fotogramas largos se puede montar en pocas líneas:

// Cuenta de fotogramas largos durante una interaccion.
// long-animation-frame informa de fotogramas de mas de 50 ms.
function contarFramesLargos(accion, ms = 2000) {
  return new Promise((resolve) => {
    let n = 0;
    const obs = new PerformanceObserver((lista) => { n += lista.getEntries().length; });
    try {
      obs.observe({ type: 'long-animation-frame', buffered: false });
    } catch {
      return resolve(null);   // el tipo de entrada no esta disponible
    }
    accion();
    setTimeout(() => { obs.disconnect(); resolve(n); }, ms);
  });
}

El try no es paranoia: long-animation-frame no está en todos los motores, y una prueba que se cae en el motor equivocado es peor que no tenerla.

Escribir código resistente

Tres decisiones reducen la superficie de este problema, y ninguna cuesta esfuerzo si se toma al principio.

Aísla las animaciones críticas del árbol. Los diálogos, menús y notificaciones que se animan viven mejor en la capa superior del documento —con dialog o con el atributo popover— que anidados en un contenedor arbitrario. No solo por composición: también por recortes, por índices de apilamiento y por desbordamientos.

Mantén los efectos de la animación en un solo elemento. Si el elemento que se mueve no tiene hijos que cambien, su textura no se invalida. A veces basta con separar en dos elementos: uno que se mueve y otro dentro que actualiza contenido.

Documenta la dependencia. Un comentario de una línea en la regla que dice que ese elemento tiene una animación compuesta y que un filter en un ancestro la rompería cuesta cinco segundos y ahorra una tarde. No es una gran práctica de ingeniería, pero es la única defensa contra un cambio en otro fichero.

⚔️ Rompe tu propia animación de siete formas
  1. Parte de una animación de translate que confirmes compuesta con la prueba del bloqueo.
  2. Rómpela añadiendo height a los fotogramas y confírmalo con la prueba.
  3. Rómpela poniendo un contador que se actualice dentro del elemento.
  4. Rómpela añadiendo filter: drop-shadow(...) a un contenedor dos niveles por encima. Anota cuánto tardas en encontrar la causa si no supieras dónde mirar.
  5. Restaura cada una y verifica con el destello de repintado que vuelve a estar compuesta.