Los hilos del navegador y por qué existe el compositor
La arquitectura de hilos de un motor moderno, qué trabajo vive en cada uno, y qué significa exactamente que una animación sobreviva a un hilo principal bloqueado.
La frase “aceleración por hardware” es responsable de más malentendidos sobre rendimiento web que ninguna otra. No describe lo que ocurre y sugiere una explicación falsa: que hay propiedades que la GPU dibuja y otras que dibuja la CPU. Lo que de verdad ocurre es un reparto de trabajo entre hilos, y lo valioso no es que la GPU sea rápida —lo es, pero eso es secundario— sino que hay un hilo que puede producir fotogramas aunque el hilo donde vive tu JavaScript esté completamente bloqueado.
- Describir el reparto de trabajo entre el hilo principal, el compositor y los hilos de rasterización.
- Explicar por qué el compositor puede producir fotogramas sin el hilo principal.
- Demostrar experimentalmente qué animaciones sobreviven a un bloqueo.
- Reconocer qué se pierde y qué no cuando el hilo principal está saturado.
El reparto
Un motor moderno reparte el trabajo de renderizado entre varios hilos, y en Chromium además entre varios procesos. Los nombres varían entre motores; el reparto no.
El hilo principal ejecuta tu JavaScript, el análisis de HTML y CSS, y las etapas de estilo, layout, prepaint y pintado. Es un único hilo, así que todo eso compite entre sí y con tu código. Cuando alguien dice “el hilo principal está bloqueado”, significa que hay una tarea de JavaScript larga y que ninguna de esas etapas puede ejecutarse hasta que termine.
El hilo del compositor recibe el árbol de capas ya pintado y se encarga de tres cosas: aplicar las transformaciones de cada capa, coordinar la rasterización y emitir la orden de dibujo a la GPU en cada refresco. Además, y esto es lo que lo hace crítico, recibe los eventos de entrada antes que el hilo principal, y puede responder a algunos sin consultarle: el desplazamiento por scroll es el ejemplo canónico.
Los hilos de rasterización ejecutan las listas de dibujo y producen píxeles, normalmente delegando en la GPU. Son un pool de trabajadores.
El proceso de GPU es quien de verdad habla con el controlador gráfico. Está separado por seguridad y estabilidad: si el controlador falla, no se lleva por delante la pestaña.
La razón de que esta arquitectura exista es concreta y se puede fechar. En los primeros navegadores, todo ocurría en un hilo, y la consecuencia era que cualquier script pesado congelaba el desplazamiento de la página. Sacar el scroll y la composición del hilo principal fue lo que hizo que la web se sintiera nativa en móvil. El hecho de que las animaciones también puedan aprovecharlo es, históricamente, un efecto secundario de haber arreglado el scroll.
Qué puede hacer el compositor sin el hilo principal
El compositor no sabe nada de CSS. Lo que tiene es un árbol de capas, y para cada capa: una textura ya rasterizada, una matriz de transformación, un valor de opacidad, un rectángulo de recorte y una lista de efectos. Con eso puede producir un fotograma.
De ahí sale la lista exacta de lo que puede cambiar por su cuenta, y explica por qué es tan corta: solo puede modificar los parámetros con los que dibuja una textura existente, no la textura.
- La matriz de transformación de una capa: desplazamiento, rotación, escala, sesgado, perspectiva.
- La opacidad con la que se compone una capa sobre lo que hay debajo.
- El desplazamiento de una capa de scroll.
- Algunos efectos que la GPU aplica al componer, como los filtros.
Cualquier cambio que altere el contenido de una capa exige volver a pintar y a rasterizar, y eso pasa por el hilo principal. Ahí está la frontera, y es la razón real de la regla de transform y opacity: no es que sean propiedades bendecidas, es que son exactamente los dos parámetros con los que el compositor dibuja una textura.
La GPU aparece en esta historia porque componer texturas es justo para lo que sirve, no porque haya un modo de dibujo especial. Un navegador sin GPU compone en la CPU y sigue teniendo la misma separación de hilos, y sigue obteniendo la misma ventaja frente a un hilo principal bloqueado.
La demostración
La forma correcta de saber si una animación está en el compositor no es leer una tabla: es bloquear el hilo principal y mirar. Este experimento cabe en una página y decide la cuestión en cinco segundos.
<div class="pista">
<div class="caja compuesta"></div>
<div class="caja principal"></div>
</div>
<button id="bloquear">Bloquear el hilo principal 2 s</button>
<style>
.pista { position: relative; height: 160px; }
.caja {
position: absolute; width: 60px; height: 60px;
background: oklch(70% 0.15 250); border-radius: 8px;
}
.compuesta { top: 0; animation: mover-t 2s linear infinite alternate; }
.principal { top: 80px; animation: mover-l 2s linear infinite alternate; }
/* Solo cambia la matriz de una capa: vive en el compositor. */
@keyframes mover-t { to { translate: 300px 0; } }
/* Cambia la geometria: exige layout en el hilo principal. */
@keyframes mover-l { from { left: 0; } to { left: 300px; } }
</style>
<script>
document.getElementById('bloquear').addEventListener('click', () => {
const fin = performance.now() + 2000;
while (performance.now() < fin) { /* ocupado a proposito */ }
});
</script>
Al pulsar el botón, la caja de arriba sigue moviéndose con total suavidad durante los dos segundos. La de abajo se queda congelada y al soltarse da un salto hasta la posición que le tocaba. No es una diferencia de rendimiento: es una diferencia de hilo.
Ese salto final merece atención, porque es la firma característica del problema en producción. Una animación no compuesta bajo carga no se ve lenta: se ve a trompicones y con saltos, porque cuando el hilo principal recupera el control calcula el fotograma que corresponde al tiempo actual, no el siguiente. El tiempo sigue corriendo aunque nadie dibuje.
Hay una conclusión errónea muy tentadora al ver la demostración anterior: que si consigues que todas tus animaciones estén en el compositor, un hilo principal bloqueado deja de ser un problema. No lo es en absoluto, y confundirlo lleva a optimizar lo que se ve en lugar de lo que importa. Lo que el compositor puede hacer sin el hilo principal es seguir dibujando y desplazar el scroll. Lo que no puede hacer es ejecutar tu manejador de clic, resolver un cambio de clase, decidir que ese botón ahora está enfocado, cambiar el cursor por una razón que dependa de CSS, ni entregar ningún evento que tenga un manejador registrado. Es decir: durante un bloqueo, la interfaz se ve viva y está muerta. El usuario ve la animación fluida, pulsa un botón, no pasa nada, y vuelve a pulsar. Eso es peor que una congelación honesta, porque una congelación comunica “espera” y una animación fluida comunica “estoy listo”. Y hay un caso que lo empeora: si el bloqueo dura lo suficiente, los clics acumulados se entregan todos de golpe cuando el hilo se libera, así que la acción se ejecuta tres veces. La conclusión práctica es que la composición es una defensa contra la percepción de lentitud, no contra la lentitud. Sigue siendo obligatorio partir las tareas largas, sacar el trabajo pesado a un Worker y ceder el control al hilo periódicamente. Una animación compuesta sobre un hilo principal saturado es maquillaje bien aplicado.
Qué se pierde y qué no bajo carga
Merece la pena tener el mapa completo de qué degrada y qué no cuando el hilo principal está ocupado, porque orienta dónde poner el esfuerzo.
Sobrevive: el movimiento de las animaciones compuestas, el desplazamiento por scroll cuando no hay manejadores bloqueantes, el redibujado de lo que ya estaba pintado, y el vídeo, que tiene su propia ruta.
No sobrevive: cualquier animación que necesite estilo, layout o pintado; el inicio y el final de las animaciones, porque los eventos del ciclo se emiten en el hilo principal; cualquier respuesta a entrada que pase por un manejador; los cambios de estado de la interfaz; y las animaciones dirigidas por scroll cuya evaluación no se haya podido delegar.
Hay un detalle contraintuitivo en esa lista: una animación compuesta se sigue moviendo pero no puede empezar ni terminar limpiamente. Si tu código espera la promesa finished para hacer algo, ese algo no ocurre hasta que el hilo se libere, aunque el movimiento visualmente haya acabado a tiempo. Es una fuente clásica de estados inconsistentes en interfaces que encadenan animaciones.
También conviene saber que el scroll tiene una excepción importante: un manejador de wheel o touchstart registrado sin passive: true obliga al compositor a esperar al hilo principal antes de desplazar, porque el manejador podría llamar a preventDefault(). Eso convierte el scroll —lo único que el compositor podía hacer solo— en algo que depende del hilo bloqueado.
// Un manejador no pasivo bloquea el scroll del compositor.
window.addEventListener('touchstart', alGesto); // mal
window.addEventListener('touchstart', alGesto, { passive: true }); // bien
- Monta la demostración de las dos cajas y confirma el comportamiento con el bloqueo de dos segundos.
- Añade una tercera caja que anime
background-colory decide, antes de probarlo, si sobrevivirá. Compruébalo. - Registra un manejador de
wheelsinpassivey comprueba qué le pasa al scroll durante el bloqueo. Añadepassive: truey repite. - Encadena una acción a la promesa
finishedde la animación compuesta y observa cuándo se ejecuta si el bloqueo ocurre justo antes del final.