Dónde está el coste de verdad: layout y paint
El alcance de la invalidación como métrica que sí predice el coste, el layout síncrono forzado y cómo detectarlo, y por qué el área que se repinta importa más que lo que se pinta en ella.
El navegador no hace trabajo proporcional a lo que has escrito, sino proporcional a lo que has invalidado. Esa es la magnitud que predice el coste, y casi nunca aparece en las guías de optimización porque no se puede reducir a una regla de escritura. Una declaración inocente en el sitio equivocado puede invalidar el documento entero; una animación aparentemente cara puede no invalidar nada. Esta lección va de aprender a estimar el alcance antes de medirlo.
- Estimar el alcance de invalidación de un cambio antes de ejecutarlo.
- Reconocer y eliminar el layout síncrono forzado.
- Distinguir el coste del área repintada del coste de lo que se pinta.
- Elegir la propiedad correcta según la etapa del pipeline que dispara.
El alcance de la invalidación
Cuando algo cambia, el motor tiene que decidir qué rehacer. Ese conjunto es el alcance, y depende de tres cosas: dónde ocurre el cambio, qué propiedad cambia y qué barreras hay entre ese punto y el resto del documento.
Sobre el “dónde”: un cambio de clase en un botón hoja del árbol afecta a un elemento. El mismo cambio en html afecta potencialmente a todo. Por eso los conmutadores de tema implementados como una clase en la raíz son el peor caso posible de invalidación —recalculan todo el documento— y por eso conviene que el cambio de tema sea una reasignación de custom properties, que también invalida mucho pero solo lo que las usa, y no una alteración de qué reglas coinciden.
Sobre el “qué”: no todas las propiedades cuestan igual, porque cada una entra en el pipeline en una etapa distinta.
| Cambias | Se dispara |
|---|---|
width, height, margin, padding, font-size, top |
estilo, layout, paint, composición |
color, background, box-shadow, border-radius |
estilo, paint, composición |
transform, opacity (en capa propia) |
solo composición |
La tercera fila es la razón entera de que las animaciones de interfaz se hagan con transform y opacity. No es que sean “más rápidas”: es que no entran en las etapas caras. Animar left recalcula la geometría en cada fotograma; animar translate mueve una textura ya pintada.
Sobre las “barreras”: si no hay ninguna, la invalidación de layout se propaga hacia arriba, porque el tamaño de un padre puede depender del de sus hijos. Un elemento que crece dentro de una tarjeta dentro de una lista dentro de la página puede obligar a recalcular la geometría hasta la raíz. Poner barreras explícitas es precisamente para lo que existe contain, y es el tema del capítulo siguiente.
Antes de escribir una animación o una actualización frecuente, respóndete: qué propiedad cambia, en cuántos elementos, y hasta dónde sube la invalidación. Si la respuesta es “una propiedad de composición, en un elemento, y no sube”, no hay nada que optimizar. Si es “una propiedad de layout, en muchos elementos, y sube hasta la raíz”, tienes un problema aunque el código parezca sencillo.
El layout síncrono forzado
Es el modo de fallo más caro que existe en el navegador y el más fácil de provocar sin darse cuenta.
El motor agrupa el trabajo: cuando modificas el DOM o el estilo, no recalcula inmediatamente, marca como sucio y espera al siguiente fotograma para hacerlo todo junto. Esa agrupación es lo que hace viable modificar cien elementos seguidos.
La agrupación se rompe cuando lees una propiedad geométrica. offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle() de una propiedad calculada a partir del layout: todas obligan al motor a calcular la geometría en ese instante, porque tiene que devolver un valor correcto y hay cambios pendientes.
Leer una vez no es un problema. El desastre es alternar:
// catastrofico: fuerza un layout por iteracion
for (const fila of filas) {
const alto = fila.offsetHeight; // lee -> fuerza layout
fila.style.height = alto * 1.5 + 'px'; // escribe -> ensucia
}
Cada lectura obliga a recalcular todo lo que la escritura anterior ensució. Con doscientas filas son doscientos layouts completos en un mismo fotograma. En el perfil aparece como una barra sólida de layout y, en el registro de rendimiento, con una advertencia explícita de reflujo forzado.
La corrección es separar las fases: leer todo primero, escribir todo después.
// una sola vez: un layout, doscientas escrituras
const altos = filas.map(f => f.offsetHeight);
filas.forEach((f, i) => { f.style.height = altos[i] * 1.5 + 'px'; });
Este patrón —leer, luego escribir— es el mismo que aplican todas las bibliotecas de animación serias, y es la razón por la que a veces son más rápidas que el código a mano que hace lo mismo.
Hay una variante silenciosa que conviene conocer: las bibliotecas de terceros también leen geometría. Un observador de intersección no fuerza layout, pero un componente que mide su propio contenido en cada render sí. Si el perfil muestra reflujos forzados que no salen de tu código, suele venir de ahí.
El área repintada, no lo que se pinta
Para el paint, la intuición correcta es geométrica: el coste es aproximadamente proporcional al área en píxeles de dispositivo que hay que rasterizar, multiplicado por la complejidad de lo que hay en ella.
Eso tiene dos consecuencias prácticas.
La primera es que un cambio pequeño en un sitio grande cuesta caro. Un background que cambia en un elemento a pantalla completa repinta la pantalla completa aunque el cambio sea de un tono. Los motores calculan un rectángulo de daño y solo repintan eso, pero el rectángulo de daño de un elemento grande es grande.
La segunda es que hay operaciones cuyo coste por píxel es muy superior al normal:
box-shadowcon radio de desenfoque grande. El desenfoque es una convolución: su coste crece con el radio, y la sombra se pinta en un área mayor que el elemento.filter: blur()igual, y encima sobre el contenido ya rasterizado.backdrop-filter. El más caro de todos, porque obliga a leer lo que hay detrás, copiarlo, filtrarlo y componerlo. En una superficie grande y en un dispositivo modesto es perfectamente capaz de comerse el presupuesto entero del fotograma.- Los gradientes complejos repetidos, en menor medida.
Ninguna de estas cosas está prohibida. Lo que hay que evitar es animar algo que las contenga, o aplicarlas a superficies enormes. Una sombra difusa en una tarjeta estática es gratis en la práctica: se pinta una vez y se compone después. La misma sombra animando su radio de desenfoque a sesenta fotogramas por segundo es una convolución por fotograma.
El truco clásico para sombras animadas sigue siendo válido y es un buen ejemplo del principio general: en lugar de animar la sombra, se pintan dos sombras en dos pseudo-elementos superpuestos y se anima la opacidad de uno de ellos. La opacidad es una propiedad de composición; el resultado visual es indistinguible y el coste por fotograma pasa de convolución a mezcla de texturas.
El CSS en el camino crítico
Hay un coste que no aparece en ningún perfil de fotogramas y que en la mayoría de los sitios reales domina a todos los demás: el CSS bloquea el renderizado. El navegador no pinta nada hasta haber descargado y parseado las hojas que aplican al documento, porque pintar antes produciría contenido sin estilo que habría que corregir.
Las consecuencias, por orden de impacto:
El tamaño total importa más que la forma. Doscientos kilobytes de CSS retrasan el primer pintado en cualquier red que no sea excelente. Da igual lo eficientes que sean los selectores de dentro.
Las cadenas de @import serializan las descargas. El navegador descubre el segundo fichero al parsear el primero. Tres niveles de @import son tres viajes de ida y vuelta en serie antes de poder pintar. La forma correcta de dividir CSS es con varios <link>, que se descargan en paralelo, o con un empaquetador.
Las media queries en el atributo media del <link> no evitan la descarga, pero sí evitan que bloqueen el renderizado si no coinciden. Es una optimización real y muy barata para CSS de impresión o de un tamaño concreto.
El CSS de un componente que aparece tarde no tiene por qué bloquear. Ahí es donde tienen sentido las estrategias de carga diferida, y donde @layer resuelve el problema de orden que las hacía peligrosas: una capa declarada antes sigue perdiendo aunque su CSS llegue después.
La forma más útil de razonar sobre esto no es “quiero que sea rápido” sino contabilidad. A sesenta fotogramas por segundo tienes 16,7 milisegundos por fotograma, y el navegador necesita una parte de ellos para su propio trabajo: recibir eventos, ejecutar animaciones, componer, presentar. Lo que te queda de verdad son alrededor de diez, y en esos diez tiene que caber tu JavaScript, el recálculo de estilo, el layout, el paint y la composición. En un portátil de desarrollo eso es tan holgado que nunca lo notas; en un teléfono de gama media de hace cuatro años —que es el dispositivo real de una fracción enorme de tus usuarios— la misma carga de trabajo cuesta entre cuatro y diez veces más, y esos diez milisegundos se convierten en cuarenta. La consecuencia que casi nadie asume es que no puedes evaluar el rendimiento en tu máquina: no es que sea impreciso, es que estás midiendo un dispositivo que no tiene ninguno de tus usuarios problemáticos. Y hay un segundo efecto, más traicionero, que explica por qué el rendimiento se degrada de golpe en lugar de poco a poco: mientras cabes en el presupuesto, añadir trabajo no tiene ningún efecto observable. Puedes duplicar el coste de tu interfaz sin que nada empeore. El día que te pasas, no aparece un retraso proporcional: se pierde un fotograma entero, y el usuario percibe un salto. Por eso los problemas de rendimiento aparecen como acantilados y no como pendientes, y por eso la única disciplina que funciona es medir con la CPU limitada de forma habitual, no cuando alguien se queja. Cuando alguien se queja ya llevas meses al borde del acantilado.