wandres.dev
EL PIPELINE DEL NAVEGADOR · Style, layout, paint, composite

Anatomía de un fotograma y el presupuesto de 16.7 ms

Las etapas que el navegador ejecuta para producir una imagen, en qué orden, y cuánto trabajo cabe realmente dentro del intervalo entre dos fotogramas.

⏱ 20 min

Todo lo que se ve en una pantalla es una sucesión de imágenes fijas. El navegador tiene que producir una completa cada vez que la pantalla se refresca, y si no llega a tiempo la pantalla repite la anterior: eso es un fotograma perdido. Entender qué hace exactamente el navegador entre dos refrescos, en qué orden y con qué coste, es lo que separa optimizar animaciones de repetir consejos. Sin este modelo, todo lo que viene después del nivel 2 son recetas que a veces funcionan.

🎯 Al terminar esta lección sabrás
  • Enumerar las etapas de producción de un fotograma en su orden real.
  • Explicar qué produce cada etapa y qué consume la siguiente.
  • Calcular el presupuesto real disponible para tu código dentro de un fotograma.
  • Situar requestAnimationFrame en el punto exacto del ciclo en que se ejecuta.

El ciclo completo

flowchart TB
vsync[Senal de refresco de la pantalla]
vsync --> entrada[1 Eventos de entrada pendientes]
entrada --> js[2 Tareas de JavaScript y callbacks de rAF]
js --> estilo[3 Estilo calcula el valor de cada propiedad]
estilo --> layout[4 Layout calcula posicion y tamano de cada caja]
layout --> capas[5 Prepaint decide que va en cada capa]
capas --> paint[6 Paint produce la lista de operaciones de dibujo]
paint --> commit[7 Commit entrega el arbol al compositor]
commit --> raster[8 Raster convierte la lista en pixeles por tiles]
raster --> draw[9 Draw la GPU compone las capas y presenta]
draw --> vsync
style vsync fill:#cba6f7,color:#11111b
style entrada fill:#89b4fa,color:#11111b
style js fill:#f9e2af,color:#11111b
style estilo fill:#f9e2af,color:#11111b
style layout fill:#f38ba8,color:#11111b
style capas fill:#89b4fa,color:#11111b
style paint fill:#f38ba8,color:#11111b
style commit fill:#89b4fa,color:#11111b
style raster fill:#fab387,color:#11111b
style draw fill:#a6e3a1,color:#11111b

Las etapas 1 a 7 ocurren en el hilo principal, el mismo que ejecuta tu JavaScript. Las etapas 8 y 9 ocurren en otros hilos y en la GPU. Esa frontera es la más importante de todo el track, y el nivel 3 está dedicado entero a ella.

Estilo. Recibe el DOM y las hojas de estilo, y produce, para cada elemento, el valor computado de cada propiedad. Aquí se resuelven selectores, cascada, herencia y funciones de valor. La salida no tiene geometría todavía: width: 50% sigue siendo el cincuenta por ciento de algo que aún no se sabe cuánto mide.

Layout. Recibe los valores computados y produce geometría: la posición y el tamaño de cada caja en píxeles. Aquí se resuelven porcentajes, dimensionado intrínseco, flexbox, grid y flujo. Es la etapa más cara y la más difícil de acotar, porque el tamaño de una caja puede depender de sus hijos, de sus hermanos y de su contenedor a la vez.

Prepaint. Decide qué elementos necesitan su propia capa de composición y construye la estructura de propiedades —transformaciones, recortes, efectos— que se aplicará a cada una. Es una etapa que casi nunca se menciona y que explica por qué añadir will-change a un elemento cambia el coste de todo lo demás.

Paint. No dibuja píxeles. Produce una lista de operaciones de dibujo: rellena este rectángulo de este color, traza este texto con esta fuente, dibuja esta imagen aquí. Es una estructura de datos, no una imagen. Que sea así es lo que permite que la siguiente etapa ocurra en otro hilo.

Commit. Entrega esa estructura al compositor. A partir de aquí el hilo principal queda libre.

Raster. Ejecuta la lista de operaciones y produce píxeles reales, normalmente por baldosas de 256 por 256 y a menudo aceleradas por GPU. Ocurre en hilos de rasterización.

Draw. El compositor coloca las baldosas ya rasterizadas según la geometría de cada capa y le pide a la GPU que las combine. Es lo único que ocurre obligatoriamente en cada fotograma.

Dónde encaja requestAnimationFrame

Los callbacks de requestAnimationFrame se ejecutan en la etapa 2, es decir antes del cálculo de estilo y de layout de ese mismo fotograma. Esa posición es la que define su utilidad y sus dos trampas.

La utilidad es evidente: cualquier cambio de estilo que hagas dentro de un callback de rAF se recoge en el paso de estilo de ese mismo fotograma, sin generar trabajo adicional. Si haces el mismo cambio desde un setTimeout, puede caer en cualquier punto del ciclo y provocar un paso de estilo extra.

La primera trampa es que leer geometría dentro de un rAF fuerza layout inmediatamente. Como el rAF corre antes del layout de ese fotograma, si pides offsetHeight el motor tiene que ejecutar estilo y layout ahí mismo para poder contestarte, y luego volverá a hacerlo en su turno si has escrito algo después. El patrón correcto es leer todo primero y escribir todo después.

// Mal: alterna lectura y escritura, y fuerza un layout por iteracion.
function malo(elementos) {
  requestAnimationFrame(() => {
    for (const el of elementos) {
      const h = el.offsetHeight;          // lee: fuerza layout
      el.style.height = `${h * 1.1}px`;   // escribe: invalida layout
    }
  });
}

// Bien: una sola fase de lectura y una sola de escritura.
function bueno(elementos) {
  requestAnimationFrame(() => {
    const alturas = elementos.map(el => el.offsetHeight);  // todas las lecturas
    for (let i = 0; i < elementos.length; i++) {
      elementos[i].style.height = `${alturas[i] * 1.1}px`; // todas las escrituras
    }
  });
}

La segunda trampa es que el argumento que recibe el callback no es el instante en que se ejecuta: es la marca de tiempo del fotograma, y todos los callbacks del mismo fotograma reciben exactamente el mismo valor. Eso es deliberado, para que varias animaciones independientes queden sincronizadas. Usar performance.now() dentro del callback en lugar del argumento produce animaciones que se desincronizan entre sí de forma sutil.

El presupuesto real

A 60 hercios hay 16.67 milisegundos entre refrescos. Ese es el presupuesto total del sistema, no el tuyo. De ahí hay que descontar todo lo que el navegador hace sin preguntarte: estilo, layout, prepaint, paint, commit y la coordinación con el compositor. La cifra que se cita habitualmente como presupuesto para el trabajo de la aplicación es de unos 10 milisegundos, y es una estimación razonable para una página de complejidad media.

Pero el presupuesto ya no es una constante. En 2026 hay pantallas de 90, 120 y 144 hercios en dispositivos perfectamente normales, y el navegador se sincroniza con la del dispositivo:

Frecuencia Intervalo Presupuesto aproximado para tu trabajo
60 Hz 16.67 ms unos 10 ms
90 Hz 11.11 ms unos 6 ms
120 Hz 8.33 ms unos 4 ms
144 Hz 6.94 ms unos 3 ms

Esa tabla desmonta cualquier razonamiento del tipo “esto tarda 8 milisegundos, cabe de sobra”. Cabe en un portátil viejo de 60 hercios y pierde uno de cada dos fotogramas en un móvil de gama alta de 120. Y el móvil de gama alta tiene además un procesador más lento por vatio que el escritorio, así que esos 8 milisegundos serán 20.

Comprobar la frecuencia real es una línea:

// Frecuencia efectiva de refresco, medida sobre 60 fotogramas.
let t0, n = 0;
function medir(t) {
  if (n === 0) t0 = t;
  if (++n < 60) return requestAnimationFrame(medir);
  console.log('Hz efectivos:', Math.round(59_000 / (t - t0)));
}
requestAnimationFrame(medir);
Perder un fotograma no cuesta 16 milisegundos: cuesta 33

La aritmética de los fotogramas perdidos es peor de lo que parece y es la razón de que un presupuesto “casi cumplido” produzca una animación visiblemente peor que uno cumplido con holgura. El navegador no puede presentar una imagen a mitad de camino: solo puede presentarla en un refresco de pantalla. Si tu trabajo tarda 18 milisegundos en un ciclo de 16.67, no llegas a ese refresco y la imagen anterior se muestra otra vez; tu fotograma sale en el siguiente, es decir a los 33.3 milisegundos. Has gastado 18 y has entregado 33. La consecuencia es una discontinuidad de velocidad: durante un intervalo el elemento no se movió y en el siguiente se movió el doble, y el sistema visual detecta ese doble salto con una facilidad brutal, mucho mayor que la que tiene para detectar una animación uniformemente más lenta. Por eso una animación estable a 30 fotogramas por segundo se ve mejor que una que oscila entre 60 y 45: la segunda tiene saltos irregulares y la primera no. Y por eso el objetivo correcto de optimización no es “bajar el tiempo medio de fotograma”, es bajar el peor fotograma. La media puede ser de 6 milisegundos y la experiencia ser mala porque uno de cada veinte fotogramas tarda 25. Cuando midas, mira el percentil 95 y el máximo; la media es la métrica que más gente engaña en este dominio, porque el usuario no percibe medias, percibe discontinuidades.

Lo que puedes saltarte y lo que no

De las nueve etapas, solo la última es obligatoria en cada fotograma. Todas las demás se ejecutan únicamente si algo las invalidó, y ahí está toda la optimización de animaciones.

Si nada cambió, el compositor vuelve a dibujar las mismas capas con la misma geometría y el coste es casi nulo. Si cambió una transformación de una capa existente, el compositor cambia una matriz y vuelve a dibujar: sigue sin haber estilo, layout, paint ni raster. Si cambió un color de fondo, hace falta paint y raster de esa zona, pero no layout. Si cambió una anchura, hace falta todo.

Esa escalera —composición, luego paint, luego layout— es el orden de coste, y es el tema de las cuatro lecciones siguientes de este nivel. Lo importante de esta lección es la idea que las sostiene: animar no es hacer trabajo, es evitar hacerlo. Una animación de 60 fotogramas por segundo que solo toca la etapa 9 hace, en total, menos trabajo que un único cambio de anchura.

⚔️ Mide tu propio ciclo
  1. Ejecuta el medidor de hercios de esta lección en tu monitor principal y en un móvil. Anota los dos números.
  2. Escribe un bucle de rAF que dibuje una animación de translate y comprueba en el panel de rendimiento qué etapas aparecen en cada fotograma.
  3. Cambia esa animación por una de width y compara la lista de etapas. Anota cuáles aparecieron y no estaban.
  4. Introduce una lectura de offsetHeight dentro del callback y localiza el layout forzado en la traza.