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:
- Abre el menú de comandos con
Cmd/Ctrl + Shift + P - Escribe “Show Rendering” y pulsa Enter
- 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.
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:
- Paint count: cuántas veces se ha repintado esa capa (es una instantánea, no un contador en vivo)
- Memory estimate: cuánta memoria de GPU ocupa
- Compositing reasons: por qué el navegador le dio (o no) capa propia
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.
#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 imagenEl 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.
- Main Thread (hilo principal): ejecuta JavaScript, calcula estilos (Recalculate Style), calcula geometría (Layout) y rasteriza píxeles (Paint). Todo lo caro vive aquí.
- Compositor Thread (hilo del compositor): coge las capas ya rasterizadas y las combina en pantalla. Puede moverlas, escalarlas y cambiar su opacidad sin volver a pintarlas, porque trabaja con bitmaps ya generados.
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
@keyframesdetransform/opacitysí 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.
#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:
- Aparece una capa nueva para
.ticker__track, separada del#document. - En sus Compositing reasons leerás una mención a
will-change: transform(el literal exacto varía entre versiones de Chrome). - Al reseleccionar la capa
#document, su Paint count ya no crece con la página en reposo. - Con Paint flashing activado, el verde desaparece: ya no se repinta nada durante la animación.
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:
- Si pones
will-changea decenas de elementos, multiplicas el consumo de memoria y puedes empeorar el rendimiento en lugar de mejorarlo, sobre todo en móviles con GPU modesta. will-changeno es un interruptor de “hazlo más rápido”. Es una reserva de recursos. Reservar lo que no usas es desperdiciar.
El criterio que aplico en auditorías:
- 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.
- Promociona solo lo que se anima de forma continua o dirigida por JS: tickers, carruseles, contadores en vivo, elementos sticky complejos.
- Pon
will-changeen el elemento concreto que se mueve, no en su contenedor ni en medio árbol. - Si la animación es esporádica, añade
will-changejusto 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.