El presupuesto de fotograma y el coste de cada tipo de trabajo
De dónde sale el número mágico, las etapas por las que pasa un fotograma, cuánto cuesta cada tipo de trabajo en órdenes de magnitud, y cómo medir esos costes en tu propio hardware.
“Tienes 16 milisegundos” es la frase más repetida y peor entendida del rendimiento web. No tienes dieciséis: tienes lo que sobre después de que el navegador haga su trabajo, y ese trabajo depende de qué le hayas pedido. Antes de perseguir jank hace falta saber cuánto vale cada cosa que puedes hacer en un fotograma, porque sin eso no hay presupuesto y sin presupuesto no hay diagnóstico, solo intuición.
- Derivar el presupuesto de fotograma desde la frecuencia de refresco y el trabajo del navegador.
- Enumerar las etapas de un fotograma y saber cuál de ellas dispara cada tipo de cambio.
- Ordenar los tipos de trabajo por coste en órdenes de magnitud.
- Medir el coste de cada etapa en tu propio hardware en lugar de citar cifras ajenas.
De dónde sale el número
Una pantalla a 60 Hz presenta una imagen nueva cada 16,67 milisegundos. El navegador está sincronizado con esa cadencia: cuando llega el pulso de refresco, si el fotograma nuevo está listo se muestra, y si no, se vuelve a mostrar el anterior. No hay medias tintas. Esa es la razón por la que perder un fotograma se nota tanto más que un retraso equivalente en cualquier otra cosa: no produce un fotograma un poco tarde, produce el fotograma anterior otra vez, y el objeto en movimiento se queda quieto durante el doble de tiempo y luego salta el doble de distancia.
De esos 16,67 ms no dispones de todos. El navegador tiene que recalcular estilo, calcular layout, pintar, subir texturas a la GPU y componer, y todo eso ocurre después de que tu código termine. La guía práctica que sale de ahí, y que lleva años siendo la referencia razonable, es que el trabajo que tú controlas debería caber en unos 10 ms a 60 Hz, dejando el resto al navegador.
Y hay un matiz que cambia el objetivo. La media no sirve: una animación con una media de 8 ms y un fotograma de 40 ms cada dos segundos se percibe peor que una constante a 12 ms. Lo que se ve es la irregularidad, no el promedio. Por eso el número que hay que mirar es el percentil alto y el máximo, y por eso la métrica correcta de una animación no es “fotogramas por segundo” sino “cuántos fotogramas se perdieron y cómo de agrupados estaban”.
Las etapas del fotograma
Este es el orden real de lo que ocurre en el hilo principal cuando llega un pulso de refresco:
- Procesar la entrada. Los eventos de puntero, teclado y scroll pendientes.
- Ejecutar los callbacks de
requestAnimationFrame. Tu código. - Recalcular estilo. Emparejar selectores y resolver valores computados para los elementos invalidados.
- Layout. Calcular geometría de las cajas afectadas.
- Actualizar el árbol de capas. Decidir qué se compone por separado.
- Pintar. Generar la lista de operaciones de dibujo por capa.
- Confirmar. Entregar el resultado al hilo del compositor.
Después, fuera del hilo principal: rasterizar las listas de dibujo a texturas y componer las texturas en la imagen final. Esas dos últimas ocurren en otros hilos y en la GPU, y son las que siguen funcionando cuando el hilo principal está bloqueado.
Lo que dispara cada etapa es la parte que hay que tener memorizada:
| Cambias | Estilo | Layout | Pintado | Composición |
|---|---|---|---|---|
transform, opacity en capa propia |
no | no | no | sí |
filter |
sí | no | según el filtro | sí |
color, background-color, box-shadow |
sí | no | sí | sí |
width, height, top, margin, font-size |
sí | sí | sí | sí |
| Añadir o quitar un nodo | sí | sí | sí | sí |
Leer offsetWidth tras escribir |
fuerza estilo y layout ahora |
Esa última fila es el layout síncrono forzado y merece su propia atención: no añade una etapa, la adelanta. Al pedir una medida el navegador no puede responderte con datos obsoletos, así que ejecuta estilo y layout en ese instante, dentro de tu callback. Si escribes y lees en un bucle, provocas un ciclo completo por iteración.
El coste de cada tipo de trabajo
Estos son órdenes de magnitud, no cifras: dependen del dispositivo, del contenido y del motor. Sirven para razonar sobre proporciones, y más abajo está la receta para obtener las tuyas.
| Trabajo | Orden de magnitud | Escala con |
|---|---|---|
| Componer capas ya rasterizadas | Microsegundos de hilo principal | Número de capas |
| Recalcular estilo de un elemento | Del orden de microsegundos | Elementos invalidados por complejidad del selector |
| Layout de un subárbol pequeño | Décimas de milisegundo | Cajas afectadas, no elementos totales |
| Layout de un documento grande | Milisegundos, a veces decenas | Cajas afectadas |
| Pintar un área pequeña | Décimas de milisegundo | Área en píxeles por complejidad |
| Pintar a pantalla completa | Milisegundos | Área por densidad de píxel al cuadrado |
| Rasterizar una capa nueva | Milisegundos, fuera del hilo principal | Área por densidad |
JSON.parse de un megabyte |
Del orden de decenas de milisegundos | Tamaño |
Un getBoundingClientRect tras escribir |
El coste completo de estilo más layout | El documento entero |
Tres lecturas que salen de esta tabla.
La diferencia entre animar transform y animar width no es de porcentajes. Una corre en el compositor sin tocar el hilo principal; la otra dispara estilo, layout, pintado y composición en cada fotograma. Comparar sus costes es comparar cero con algo.
El área importa más que la complejidad en el pintado. Un degradado complicado en un botón cuesta menos que un color plano a pantalla completa, porque el rasterizador rellena píxeles y hay muchísimos más píxeles en la segunda. Y el área crece con el cuadrado de la densidad: la misma región en un dispositivo con devicePixelRatio 3 tiene nueve veces más píxeles que en uno con 1.
Lo que invalida importa más que lo que cambia. Un cambio de color en un elemento del que cuelga la mitad del documento invalida esa mitad. Un cambio de width en un contenedor de flujo normal reordena todo lo que viene después. Por eso contain y las capas existen: acotan la propagación.
Medir en tu contexto
Cita cifras propias, no ajenas. Estas tres medidas se obtienen en diez minutos y valen para toda la vida del proyecto.
El coste de recalcular estilo y de hacer layout en tu documento. Fuerza el ciclo y mídelo:
function medirCicloCompleto() {
const t0 = performance.now();
document.body.style.setProperty("--sonda", Math.random().toFixed(6));
// Leer una propiedad geometrica fuerza estilo y layout ahora mismo.
void document.body.offsetHeight;
return performance.now() - t0;
}
const muestras = Array.from({ length: 50 }, medirCicloCompleto).sort((a, b) => a - b);
console.log(
`estilo+layout mediana ${muestras[25].toFixed(2)} ms p95 ${muestras[47].toFixed(2)} ms`
);
Ejecútalo en una pantalla vacía y en tu pantalla más pesada. La diferencia entre ambas es lo que cuesta tu propio DOM, y suele ser mucho mayor de lo que la gente espera.
El coste real de un fotograma completo. La API de fotogramas largos de animación reporta cualquier fotograma que pase de cincuenta milisegundos, con el desglose entre script, estilo y layout:
try {
new PerformanceObserver((lista) => {
for (const e of lista.getEntries()) {
const render = e.styleAndLayoutStart - e.renderStart;
const guion = e.scripts.reduce((s, x) => s + x.duration, 0);
console.warn(
`frame ${e.duration.toFixed(0)} ms script ${guion.toFixed(0)} render ${render.toFixed(0)} bloqueo ${e.blockingDuration.toFixed(0)}`
);
}
}).observe({ type: "long-animation-frame", buffered: true });
} catch {}
La cadencia real durante una animación. Cuenta los intervalos entre fotogramas y clasifícalos. Un intervalo cercano al doble del período es un fotograma perdido.
function medirCadencia(ms = 3000) {
const intervalos = [];
let anterior = null;
const inicio = performance.now();
function paso(t) {
if (anterior !== null) intervalos.push(t - anterior);
anterior = t;
if (t - inicio < ms) requestAnimationFrame(paso);
else informar(intervalos);
}
requestAnimationFrame(paso);
}
function informar(intervalos) {
const periodo = Math.min(...intervalos);
const perdidos = intervalos.filter((d) => d > periodo * 1.5).length;
const orden = [...intervalos].sort((a, b) => a - b);
console.log(
`periodo ~${periodo.toFixed(1)} ms perdidos ${perdidos}/${intervalos.length}` +
` p95 ${orden[Math.floor(orden.length * 0.95)].toFixed(1)} ms`
);
}
Tu máquina de desarrollo es entre cuatro y diez veces más rápida que el teléfono de una parte importante de tus usuarios. Todas estas medidas hay que tomarlas también con el estrangulamiento de CPU a 4x —Android de gama media— o a 6x —gama baja— desde el panel de rendimiento. Es la diferencia entre “esto va bien” y “esto va bien en mi portátil”.
Cuando la pantalla va a 120 Hz
Cada vez más dispositivos refrescan a 90, 120 o más. El presupuesto se parte por la mitad —8,33 ms a 120 Hz— pero el trabajo del navegador no se parte por la mitad: pintar un área es igual de caro sea cual sea la frecuencia. El margen que te queda no baja de diez a cinco: baja mucho más.
Dos consecuencias.
No asumas 16,67 en tus cálculos de delta. Si tu animación avanza un valor fijo por fotograma, en una pantalla a 120 Hz irá al doble de velocidad. Se resuelve usando el timestamp que recibe el callback de requestAnimationFrame y calculando el avance por tiempo transcurrido, no por fotograma.
let anterior = null;
const VELOCIDAD = 240; // pixeles por segundo
function paso(t) {
if (anterior !== null) {
const dt = Math.min((t - anterior) / 1000, 0.05); // acota el salto tras un bloqueo
posicion += VELOCIDAD * dt;
elemento.style.transform = `translateX(${posicion}px)`;
}
anterior = t;
requestAnimationFrame(paso);
}
requestAnimationFrame(paso);
Ese Math.min es importante: si el hilo estuvo bloqueado medio segundo, sin acotar el delta el objeto teletransporta doscientos píxeles de golpe. Acotarlo lo convierte en una pausa, que se percibe mucho mejor que un salto.
Las animaciones del compositor son inmunes a esto. Una transición de transform declarada en CSS se interpola contra el reloj, no contra los fotogramas, y el compositor la muestrea a la frecuencia que haya. Es una razón más para dejar en manos del navegador todo lo que él sepa hacer.
La forma habitual de pensar el presupuesto —tantos milisegundos para esta animación, tantos para aquella— es cómoda y engañosa, porque asume que todas las animaciones compiten por el mismo recurso y no es así. Una animación de transform sobre una capa propia no consume nada del presupuesto del hilo principal: la interpolación la hace el compositor con su propio reloj y su propio hilo, y podrías tener cincuenta corriendo mientras el hilo principal está completamente bloqueado, y las cincuenta seguirían fluidas. Una animación de width, en cambio, consume estilo, layout, pintado y composición en el hilo principal, y una sola puede llevarse el presupuesto entero. Así que el reparto correcto no es por animación sino por etapa, y la etapa escasa es siempre el hilo principal, porque es el único recurso del que solo hay uno y que además comparte con toda tu lógica de aplicación, tus manejadores de eventos, tu framework y tu recolector de basura. Esta forma de mirarlo cambia dos decisiones que suelen tomarse mal. La primera: la pregunta al añadir una animación no es cuánto va a costar, sino en qué etapa va a caer, porque si cae en el compositor la respuesta a cuánto cuesta es esencialmente cero y la conversación se acaba ahí. La segunda: cuando algo va mal, el culpable casi nunca es la animación que se ve mal. Una animación de compositor que tartamudea no tiene un problema propio; tiene un vecino que está bloqueando el hilo principal el tiempo suficiente para retrasar la confirmación de fotogramas, o está compitiendo por memoria de GPU, o alguien está forzando layout en un manejador de scroll. Buscar la causa dentro de la animación que se ve mal es el error de diagnóstico más común de todo el rendimiento web, y entender que el presupuesto es por etapa y no por elemento es exactamente lo que te lleva a mirar en el sitio correcto.