wandres.dev
EL BUCLE DE RENDER · requestAnimationFrame y el tiempo

Cuándo sigues necesitando requestAnimationFrame

Después de CSS y WAAPI quedan cuatro familias de problemas que ninguna API declarativa cubre, y una lista igual de importante de casos donde rAF es la respuesta equivocada.

⏱ 16 min

Con transiciones, keyframes y el objeto Animation ya cubres casi todo lo que se mueve en una interfaz, y lo cubres mejor de lo que lo cubriría un bucle propio: con aceleración en el compositor, con control de reproducción y sin gastar hilo principal. Queda un resto, y no es pequeño. requestAnimationFrame sigue siendo necesario cuando el valor del frame siguiente no se puede conocer de antemano, porque depende del estado del frame anterior o de algo que ocurre fuera del modelo de animación.

🎯 Al terminar esta lección sabrás
  • Identificar las cuatro familias de problemas que exigen un bucle propio.
  • Descartar rAF en los casos donde WAAPI hace lo mismo mejor.
  • Escribir un bucle mínimo correcto y saber qué garantiza el navegador.
  • Situar rAF dentro del ciclo de actualización del frame.

Las cuatro familias que lo necesitan

Simulación con estado. Cualquier cosa cuyo valor siguiente sea función del valor actual y no del tiempo transcurrido. Un muelle que persigue un objetivo que se mueve, un sistema de partículas con colisiones, un valor que sigue al puntero con suavizado. Aquí no hay una lista de keyframes que escribir porque el destino cambia mientras se va hacia él.

let x = 0, objetivo = 0;

addEventListener('pointermove', (e) => { objetivo = e.clientX; });

function bucle() {
  x += (objetivo - x) * 0.12;          // depende del valor anterior
  cursor.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(bucle);
}
requestAnimationFrame(bucle);

Dibujo directo. canvas en dos dimensiones, WebGL y WebGPU no tienen propiedades animables: la única forma de que cambie lo que se ve es volver a dibujar. El bucle no es opcional, es el mecanismo.

Lectura continua de algo externo. La posición del puntero, el desplazamiento de un contenedor, un sensor de movimiento, la salida de un analizador de audio. Sincronizar esas lecturas con el ritmo de pintado es exactamente para lo que sirve rAF.

Coordinación con lo que no es la web. Sincronizar visuales con el tiempo de reproducción de un video o de un AudioContext, cuyos relojes son independientes del de las animaciones y hay que consultar cada frame.

Dónde rAF es la respuesta equivocada

Y ahora la lista contraria, que es la que más código malo evita.

Si estás escribiendo el.style.transform en cada frame con un valor que sale de una interpolación entre dos extremos conocidos, eso es una animación y va en WAAPI o en CSS. Tu bucle corre en el hilo principal y se atasca cuando algo lo bloquea; la animación equivalente vive en el compositor y no se entera.

Si estás usando rAF para “esperar al siguiente frame” antes de aplicar una clase y que la transición arranque, eso es un apaño contra la agrupación de cambios de estilo, y el doble requestAnimationFrame que circula por ahí funciona por accidente. La forma robusta es forzar el cálculo de estilo leyendo una propiedad, o directamente usar WAAPI, que no tiene ese problema porque la animación se crea con su estado inicial explícito.

Si estás midiendo el tiempo con rAF para disparar algo dentro de N milisegundos, un temporizador es más barato y no obliga al navegador a pintar. rAF no es un temporizador: es una petición de participar en el próximo frame.

Y si estás animando N elementos con el mismo movimiento desde un bucle, cada uno de esos style.transform es una escritura al DOM y un recálculo de estilo. Cien elementos animados con WAAPI son cien animaciones que el motor puede repartir; cien elementos animados desde un bucle son cien escrituras por frame en tu hilo.

El bucle mínimo correcto

let id = null;

function frame(ts) {
  // ts es un DOMHighResTimeStamp, en la misma escala que performance.now()
  dibujar(ts);
  id = requestAnimationFrame(frame);
}

id = requestAnimationFrame(frame);

Tres cosas que el navegador garantiza y conviene tener claras.

La primera: el callback recibe un marcador de tiempo del frame, no la hora actual. Es el instante en que empezó la actualización de este frame, y todos los callbacks registrados para el mismo frame reciben exactamente el mismo valor. Usar performance.now() dentro del callback devuelve un número distinto y ligeramente mayor, y usarlo para animar introduce fluctuación.

La segunda: la petición es para un solo frame. Si quieres un bucle, hay que volver a pedir dentro del callback. Esto es deliberado y es lo que hace que el bucle se detenga solo si dejas de pedir, en vez de tener que acordarte de cancelarlo.

La tercera: el navegador ejecuta los callbacks dentro de su ciclo de actualización, antes del cálculo de estilo y del layout de ese frame. Por eso escribir estilos en un callback se aplica en el mismo frame, y por eso leer geometría en un callback devuelve el layout del frame anterior, que sigue siendo válido porque nada lo ha invalidado todavía.

Esa tercera propiedad es lo que hace de rAF el sitio correcto para agrupar lecturas y escrituras: lee todo lo que necesitas al principio del callback, calcula, y escribe al final. Si intercalas lectura y escritura, cada lectura después de una escritura fuerza un cálculo de layout sincrónico.

El presupuesto real

A 60Hz tienes 16.67 milisegundos por frame, y no son tuyos: de ahí sale el cálculo de estilo, el layout, el pintado y la composición del navegador, más cualquier otro callback registrado. En la práctica, un bucle que consuma más de cuatro o cinco milisegundos ya empieza a comerse el margen.

A 120Hz el presupuesto es 8.33 milisegundos, la mitad. Y los monitores de alta frecuencia ya no son minoritarios: los teléfonos de gama media los traen. Un bucle que iba justo a 60Hz no va justo a 120Hz, va mal, porque el trabajo por frame no baja y el tiempo disponible sí.

Nivel dios

El navegador no llama a tus callbacks si no va a pintar. Es la parte del contrato que la gente descubre tarde y mal. Con la pestaña en segundo plano, con la ventana minimizada o con el elemento fuera de la pantalla en algunos motores, el bucle se detiene, no se ralentiza: el callback deja de ejecutarse durante minutos y vuelve de golpe cuando la pestaña recupera la visibilidad. Eso es lo correcto para el rendimiento y la batería, y es un desastre para cualquier bucle que asuma continuidad. Si tu código lleva la cuenta de algo sumando por frame, al volver de segundo plano habrá perdido minutos de cuenta; si calcula un delta contra el frame anterior, ese delta valdrá doscientos mil milisegundos y la simulación explotará en un solo paso. Un bucle que solo se prueba con la pestaña en primer plano nunca enseña este bug, y en producción lo sufre cualquier usuario que cambie de pestaña. La defensa tiene dos partes: acotar el delta a un máximo razonable, y escuchar visibilitychange para reajustar el reloj al volver. Ninguna de las dos es opcional en un bucle de larga vida.

Lo que viene

Con el bucle en marcha aparece inmediatamente el problema central del nivel, que es el tiempo. Una animación escrita en un bucle no tiene duración declarada ni curva: tiene una cantidad de cambio por frame, y esa cantidad hay que derivarla del tiempo transcurrido si quieres que el movimiento sea el mismo en todas las pantallas.

Escribirlo mal es la norma, no la excepción, y el error tiene una firma inconfundible: la animación va al doble de velocidad en un monitor de 120Hz. Eso es lo siguiente.

⚔️ Reto práctico

Monta el bucle del cursor con suavizado y déjalo corriendo. Cambia a otra pestaña durante treinta segundos y vuelve. Registra en consola la diferencia entre el ts de este frame y el del anterior en el primer frame tras volver, y comprueba el orden de magnitud. Después haz lo mismo con performance.now() en vez del argumento y compara los dos números dentro del mismo callback.