wandres.dev
RENDIMIENTO · Medir el jank de verdad

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.

⏱ 19 min

“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.

🎯 Al terminar esta lección sabrás
  • 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:

  1. Procesar la entrada. Los eventos de puntero, teclado y scroll pendientes.
  2. Ejecutar los callbacks de requestAnimationFrame. Tu código.
  3. Recalcular estilo. Emparejar selectores y resolver valores computados para los elementos invalidados.
  4. Layout. Calcular geometría de las cajas afectadas.
  5. Actualizar el árbol de capas. Decidir qué se compone por separado.
  6. Pintar. Generar la lista de operaciones de dibujo por capa.
  7. 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
filter no según el filtro
color, background-color, box-shadow no
width, height, top, margin, font-size
Añadir o quitar un nodo
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`
  );
}
⚠️
Sin estrangulamiento de CPU, ninguna de estas medidas significa nada

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.

El presupuesto no se reparte entre animaciones: se reparte entre etapas, y el hilo principal es el único recurso escaso

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.