Skip to content

El misterio del #document que se repinta solo: Paint flashing y capas de composición

Published:

En una auditoría reciente abrí una página de producto, no toqué nada y dejé el ratón quieto. La página estaba en reposo: ningún scroll, ningún clic, ninguna petición de red. Y aun así el ventilador del portátil empezó a notarse. Abrí la pestaña Layers de Chrome DevTools y ahí estaba el indicio: la capa #document marcaba un Paint count altísimo, y cada vez que lo volvía a comprobar había subido todavía más, con la página detenida.

Cuando una web en reposo sigue repintando, está gastando batería y CPU para no mostrar nada nuevo. En un portátil se nota en el ventilador; en un móvil se nota en la autonomía. Casi siempre el culpable es un componente dinámico pequeño, un contador, un ticker de cotizaciones, un carrusel de texto, que anima bien pero pinta mal.

El síntoma: Paint count disparado en reposo

La señal de alarma es simple: una página quieta no debería repintar nada. Si dejas de interactuar y el navegador sigue trabajando, hay una animación corriendo por debajo que está invalidando píxeles cada frame.

Lo interesante de este caso es que el desarrollador había hecho los deberes. La animación del ticker usaba transform, una de las propiedades que todo el mundo recomienda porque “no provoca layout ni paint”. Y sin embargo el #document se repintaba sin descanso. La teoría decía una cosa y el Paint count decía la contraria.

Herramientas de diagnóstico

Para cazar este tipo de problema hay dos herramientas en Chrome DevTools que trabajan juntas.

Paint flashing (panel Rendering)

Pinta de verde cada región de la pantalla en el momento en que el navegador la repinta. Para activarlo:

  1. Abre el menú de comandos con Cmd/Ctrl + Shift + P
  2. Escribe “Show Rendering” y pulsa Enter
  3. Marca la casilla Paint flashing

Ahora deja la página en reposo y observa. Si ves parpadeos verdes constantes sobre el ticker (o peor, sobre una zona enorme de la página), el navegador está repintando en cada frame. La extensión del verde te dice cuánta superficie se invalida: muchas veces no es solo el componente, es toda la capa que lo contiene.

Panel Rendering de Chrome DevTools con la casilla Paint flashing activada; el .ticker__track aparece resaltado en verde porque se repinta en cada frame, mientras el contador de repaints del #document sigue subiendo.
Con Paint flashing activado, el navegador tiñe de verde la zona del ticker en cada frame: la señal en tiempo real de que algo se repinta con la página en reposo. Ampliar imagen

Pestaña Layers

La pestaña Layers muestra el árbol de capas de composición de la página. Para abrirla, menú de comandos → “Show Layers”. De cada capa te interesa:

Selecciona la capa #document con la página en reposo y mira su Paint count. Aquí conviene conocer un detalle de la herramienta: el Paint count del panel Layers no se actualiza en vivo. Es una instantánea que solo se refresca cuando vuelves a seleccionar el nodo, así que no lo verás subir solo mientras lo tienes seleccionado.

Para confirmar que crece, selecciona otra capa y vuelve a #document cada pocos segundos: si el número es mayor cada vez con la página parada, ya tienes la confirmación de que el ticker no tiene capa propia y arrastra a toda la página del documento a repintarse con él. Para la señal en tiempo real apóyate en Paint flashing; el Paint count del panel Layers es la confirmación numérica acumulada.

Pestaña Layers de Chrome DevTools con la capa #document seleccionada: muestra un Paint count alto y creciente y, en Compositing reasons, que es el document.rootScroller con scroll acelerado.
En la pestaña Layers, la capa #document acumula un Paint count alto y creciente porque el ticker comparte capa con ella. El número es una instantánea: vuelve a seleccionar el nodo para verlo subir. Ampliar imagen

El escenario: un ticker que anima bien y pinta mal

Este es el tipo de componente que aparece una y otra vez: un ticker de cotizaciones que desplaza los valores en bucle. El HTML y el CSS son intachables.

<div class="ticker">
  <div class="ticker__track">
    <span>EUR/USD 1.0921 ▲</span>
    <span>GBP/USD 1.2734 ▼</span>
    <span>USD/JPY 149.82 ▲</span>
    <!-- …the set of values is duplicated for a seamless loop… -->
  </div>
</div>
.ticker {
  overflow: hidden;
}

.ticker__track {
  display: inline-flex;
  gap: 2rem;
  white-space: nowrap;
  /* We only move transform: the compositor's "cheap" property */
}

Y la lógica del desplazamiento, en JavaScript con requestAnimationFrame para mover el track frame a frame:

// The track scrolls by mutating transform every frame.
// Since it's a JS-driven animation, Chrome won't promote it on its own.
const track = document.querySelector(".ticker__track");
let x = 0;

function tick() {
  x -= 0.6;
  if (-x >= track.scrollWidth / 2) x += track.scrollWidth / 2;
  track.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(tick);
}

requestAnimationFrame(tick);

Sobre el papel es un ejemplo de manual: solo movemos transform, no tocamos propiedades que disparen layout. Y aun así, Paint flashing pinta de verde la zona del ticker en cada frame, y el #document se repinta sin descanso mientras el ticker se desliza.

El problema no es qué propiedades animamos, sino dónde viven los píxeles que se mueven.

La explicación: dos hilos, una sola capa

Para entender por qué transform no basta hay que separar dos cosas que ocurren en hilos distintos.

La promesa de transform y opacity es esta: si una capa ya está rasterizada, el compositor puede desplazarla u opacarla por su cuenta, sin molestar al hilo principal y sin repintar. Animación gratis en la GPU.

Pero esa promesa tiene una condición que casi nadie menciona: el elemento que se mueve tiene que estar en su propia capa de composición (Composited Layer). Si el ticker comparte capa con el #document, el compositor no puede moverlo de forma aislada, porque mover ese trozo significaría dejar un hueco en el bitmap de toda la página. La única salida del navegador es volver a rasterizar la capa entera del documento en cada frame de la animación.

Ahí está la trampa: usar transform no te da composición por arte de magia. Te da composición solo si el elemento ya tiene capa propia. Sin promoción, transform se rasteriza como cualquier otra cosa, y arrastra a toda la capa que lo contiene.

Matiz importante para ser honestos: una animación declarativa con @keyframes de transform/opacity sí suele promocionarse de forma automática, Chrome detecta la animación y le da capa propia. El caso que repinta es justo este: una animación dirigida por JavaScript (cambiando estilos inline frame a frame, o disparando transiciones desde código). El navegador no sabe de antemano que eso es una animación continua, así que no promociona el elemento por iniciativa propia. Por eso los tickers alimentados por datos en tiempo real son reincidentes habituales de este problema.

La solución: promocionar el elemento a su propia capa

La corrección es decirle al navegador, de forma explícita, que ese elemento va a cambiar su transform, para que le reserve una capa de composición antes de que empiece el movimiento.

.ticker__track {
  display: inline-flex;
  gap: 2rem;

  /* Promote the element to its own composited layer */
  will-change: transform;
}

Activa el toggle de la demo para ver el cambio en directo: el contador de repaints del #document se congela y el ticker pasa a tener su propia capa de composición.

Deja la demo en reposo y observa el contador de repaints del #document subir solo. Activa will-change para promocionar el ticker a su propia capa y ver cómo se detiene. Abrir la demo en una pestaña nueva ↗ para inspeccionarla con tus propias DevTools (pestañas Layers y Rendering), o ábrela ya corregida con ?promoted para comprobar que el #document no se repinta desde el primer render.

Lo compruebas también en la pestaña Layers:

La alternativa clásica, anterior a will-change, es forzar la capa con una transformación 3D:

.ticker__track {
  transform: translateZ(0); /* the old GPU-promotion "hack" */
}

Funciona porque cualquier transformación 3D obliga al navegador a crear una capa. Pero will-change es la herramienta correcta hoy: expresa la intención (“esto va a cambiar”) en lugar de un efecto colateral, y el navegador puede gestionar la capa de forma más inteligente, creándola justo antes de la animación y liberándola después.

La regla de oro: promociona con cabeza

Llegados aquí aparece la tentación de poner will-change: transform a todo lo que se mueva. Mala idea. Cada capa de composición consume memoria de GPU, y promocionar de más tiene su propio coste:

El criterio que aplico en auditorías:

  1. Mídelo primero. Si la pestaña Layers y Paint flashing no muestran repintados en reposo, no hay nada que promocionar. No optimices a ciegas.
  2. Promociona solo lo que se anima de forma continua o dirigida por JS: tickers, carruseles, contadores en vivo, elementos sticky complejos.
  3. Pon will-change en el elemento concreto que se mueve, no en su contenedor ni en medio árbol.
  4. Si la animación es esporádica, añade will-change justo antes (al pasar el ratón, al iniciar) y quítalo al terminar, para no mantener la capa reservada de forma permanente.

Una sola capa bien puesta arregla un #document que se repinta solo. Diez capas mal puestas crean un problema nuevo de memoria. La integridad del rendimiento no consiste en promocionarlo todo, sino en promocionar lo justo, allí donde la medición lo pide.

Conclusión

transform y opacity son las propiedades correctas para animar, pero no son mágicas: solo viajan por el compositor si el elemento tiene su propia capa de composición. Cuando un componente dinámico, casi siempre dirigido por JavaScript, anima sin capa propia, arrastra a toda la capa #document a repintarse en cada frame, y eso se ve en el Paint count y en el verde de Paint flashing aun con la página en reposo. La solución es promocionar ese elemento concreto con will-change para la propiedad que anima, verificar en la pestaña Layers que el #document deja de repintarse, y resistir la tentación de promocionarlo todo.

Referencias


Next Post
Antigravity Skills: configuración avanzada del frontmatter YAML