La pista del hilo principal: el recurso más escaso del navegador
Qué comparte el hilo principal, cómo se estructura su trabajo en tareas y fotogramas, qué significan los bloques de estilo y disposición, y cómo se lee su saturación.
Un navegador tiene muchos hilos y solo uno importa para casi todos los problemas de rendimiento de una aplicación: aquel donde se ejecuta tu JavaScript, se calculan los estilos, se resuelve la disposición y se decide qué pintar. Ese hilo es también el que atiende los eventos del usuario. Como no puede hacer dos cosas a la vez, cada milisegundo que le das a una cosa se lo quitas a otra, y ahí está el origen de la práctica totalidad de las congelaciones de interfaz. La pista del hilo principal es el mapa de esa competencia.
- Enumerar qué actividades comparten el hilo principal y cuáles no.
- Interpretar la estructura de tareas y sus etapas dentro de un fotograma.
- Identificar los bloques de recálculo de estilo y de disposición y qué los provocó.
- Evaluar la saturación del hilo principal en un intervalo concreto.
Qué comparte ese hilo
Todo esto ocurre en el mismo carril de ejecución y se turna:
El análisis del HTML y la construcción del árbol de documento. La ejecución de todo el JavaScript de la página, incluido el de terceros. La evaluación de qué reglas CSS aplican a cada elemento. El cálculo de la geometría de cada caja. La construcción de la lista de operaciones de pintado. La atención a los eventos de entrada del usuario. Y la ejecución de las devoluciones de llamada de temporizadores, promesas, observadores y de la sincronización con el fotograma.
Y esto no ocurre ahí: la conversión de la lista de pintado en píxeles, que sucede en hilos de rasterización; la composición de las capas y su presentación, que sucede en el hilo del compositor y en la GPU; y todo lo que ejecute un worker.
Esa frontera explica la propiedad más útil del pipeline de render: una animación que solo cambia transformación y opacidad puede seguir moviéndose con el hilo principal completamente bloqueado, porque la maneja el compositor. Cualquier animación que cambie geometría o color de fondo necesita al hilo principal en cada fotograma y se congela con él.
Un worker es la única forma real de sacar trabajo de este hilo. Pero solo mueve el cómputo: no puede tocar el DOM, y el coste de serializar datos de ida y vuelta puede comerse la ganancia si lo que mueves es pequeño. La pista del worker en el perfil es lo que permite comprobar si la descarga está funcionando de verdad o si el hilo principal sigue haciendo casi todo mientras el worker está ocioso.
Tareas, y qué hay dentro de una tarea
El trabajo del hilo principal se organiza en tareas. Una tarea es una unidad indivisible: una vez empieza, se ejecuta hasta el final, y nada puede interrumpirla. Ni un clic, ni un fotograma, ni un temporizador. Esa indivisibilidad es la razón de que exista el concepto de tarea larga y de que la capacidad de respuesta se mida como se mide.
En el perfil, cada tarea aparece como una caja de primer nivel en la pista, y todo lo que hay debajo es su contenido. Las tareas que superan cierto umbral se marcan visualmente con una diagonal en la parte superior, y ese marcado es la primera cosa que hay que buscar.
Dentro de una tarea que produce un fotograma, el trabajo tiene un orden fijo:
flowchart TB a[Entra la tarea] --> b[Ejecucion de JavaScript] b --> c[Callbacks de animacion del fotograma] c --> d[Recalculo de estilo] d --> e[Disposicion] e --> f[Actualizacion de capas] f --> g[Construccion de la lista de pintado] g --> h[Fuera del hilo principal rasterizar y componer] style a fill:#cba6f7,color:#11111b style b fill:#f9e2af,color:#11111b style d fill:#89b4fa,color:#11111b style e fill:#89b4fa,color:#11111b style g fill:#f38ba8,color:#11111b style h fill:#a6e3a1,color:#11111b
Reconocer ese orden en el perfil sirve para diagnosticar por posición. Un bloque de disposición dentro del bloque de ejecución de JavaScript, y no después, significa que el código forzó el cálculo leyendo una propiedad geométrica. Eso es una disposición sincrónica forzada, y es un patrón con nombre y con arreglo conocido.
Los bloques de estilo y de disposición
Dos tipos de bloque merecen lectura específica porque son los que más veces sorprenden.
Recálculo de estilo. El navegador decide qué declaraciones aplican a qué elementos. Al seleccionar el bloque, el panel indica cuántos elementos se recalcularon y, con frecuencia, qué lo provocó. Los números importan: recalcular treinta elementos es gratis, recalcular ocho mil no. Las causas habituales de un recálculo enorme son cambiar una clase en un ancestro muy alto, modificar una variable CSS que se hereda por todo el árbol, o insertar una hoja de estilos.
Disposición. El navegador calcula posición y tamaño. El panel indica cuántos nodos entraron en el cálculo y cuántos formaban el árbol. Una disposición cara casi siempre es una disposición de todo el documento provocada por un cambio en un elemento alto. La forma de reducirla no es que el cálculo sea más rápido sino que abarque menos: contención, o elementos que no participen del flujo.
Cuando el panel detecta que una lectura de propiedad geométrica obligó a recalcular la disposición en mitad de la ejecución de un script, lo señala explícitamente y ofrece el enlace a la línea responsable. Es una de las pistas más directamente accionables de todo el panel.
Medir la saturación
Mirar la pista da una impresión cualitativa; a veces hace falta un número. Este fragmento mide qué fracción del tiempo está ocupado el hilo principal y cuánto duran sus tareas, usando la API de fotogramas de animación largos, que además atribuye cada uno a los scripts que lo causaron.
// Observa fotogramas de animacion largos y atribuye el trabajo
(() => {
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
console.warn('Este navegador no expone long-animation-frame.');
return;
}
const registro = [];
const obs = new PerformanceObserver(lista => {
for (const f of lista.getEntries()) {
registro.push({
inicioMs: Math.round(f.startTime),
duracionMs: Math.round(f.duration),
bloqueanteMs: Math.round(f.blockingDuration),
estiloYLayoutMs: Math.round(f.styleAndLayoutStart ? f.startTime + f.duration - f.styleAndLayoutStart : 0),
scripts: f.scripts.map(s => ({
invocador: s.invoker,
tipo: s.invokerType,
fuente: (s.sourceURL || '').split('/').pop() + (s.sourceFunctionName ? ' :: ' + s.sourceFunctionName : ''),
ms: Math.round(s.duration),
reflowForzadoMs: Math.round(s.forcedStyleAndLayoutDuration)
}))
});
}
});
obs.observe({ type: 'long-animation-frame', buffered: true });
window.informeLoAF = () => {
console.table(registro.map(({ scripts, ...r }) => ({ ...r, nScripts: scripts.length })));
const porFuente = {};
for (const f of registro) for (const s of f.scripts) {
porFuente[s.fuente] = porFuente[s.fuente] || { fuente: s.fuente, ms: 0, veces: 0, reflowMs: 0 };
porFuente[s.fuente].ms += s.ms;
porFuente[s.fuente].veces++;
porFuente[s.fuente].reflowMs += s.reflowForzadoMs;
}
console.table(Object.values(porFuente).sort((a, b) => b.ms - a.ms).slice(0, 15));
return registro.length + ' fotogramas largos registrados';
};
console.log('Observando. Interactua con la pagina y luego ejecuta informeLoAF()');
})();
La segunda tabla es la valiosa: agrupa por origen del script y suma. La columna de reflujo forzado es la que delata el acceso a propiedades geométricas en mitad de un manejador. Y la duración bloqueante es la parte del fotograma que superó el umbral, que es la magnitud que se correlaciona con lo que el usuario percibe.
Un detalle de método: este observador funciona en producción. A diferencia del panel, no requiere que nadie tenga las DevTools abiertas, así que la misma instrumentación sirve para descubrir en campo qué scripts producen fotogramas largos en dispositivos reales, que es información que ningún laboratorio da.
Hay una intuición que hay que desmontar para diagnosticar bien, y es la de que un hilo principal ocupado es malo. No lo es: un hilo principal ocupado el noventa por ciento del tiempo en tareas de tres milisegundos produce una interfaz perfectamente fluida, y un hilo principal ocupado el veinte por ciento en una sola tarea de seiscientos milisegundos produce una congelación que todo el mundo nota. La cantidad total de trabajo apenas se correlaciona con la experiencia; lo que se correlaciona es la distribución de la longitud de las tareas, y en particular su cola larga. La razón es la indivisibilidad: mientras una tarea corre, el navegador no puede atender un clic, no puede producir un fotograma y no puede hacer absolutamente nada. Si la tarea dura seis milisegundos, el clic espera como mucho seis milisegundos y nadie lo percibe. Si dura seiscientos, el usuario pulsa, no pasa nada, vuelve a pulsar, y acabas con dos envíos del formulario. Esto reorienta por completo el trabajo de optimización. La pregunta útil no es “cómo hago esto más rápido” sino “cómo lo parto”. Y partir es casi siempre más fácil que acelerar: procesar mil elementos en veinte lotes de cincuenta con una cesión del control entre lotes cuesta exactamente el mismo tiempo total, no requiere ningún algoritmo mejor, y convierte una congelación de medio segundo en veinte pausas imperceptibles. Las herramientas para ceder el control existen y no son equivalentes entre sí: scheduler.yield() cede manteniendo la prioridad y volviendo antes que cualquier tarea nueva, lo que evita que ceder te penalice; scheduler.postTask() permite programar con prioridad explícita; requestIdleCallback sirve para trabajo que puede esperar indefinidamente y no para trabajo que el usuario está esperando; y setTimeout con cero es el recurso de compatibilidad, con el defecto de que tu continuación se pone al final de una cola donde puede colarse cualquiera. La regla práctica que resume el nivel entero: ninguna tarea que pueda coincidir con una interacción del usuario debería durar más de unos pocos milisegundos, y cualquier bucle sobre una colección de tamaño no acotado es una tarea larga esperando a que los datos crezcan.