Eventos y tiempos · 2 min de lectura

Tareas, microtareas y requestAnimationFrame: quién va primero

El event loop no tiene misterio, pero su orden explica muchos errores raros, y por qué una promesa puede congelar la página.

En el navegador, JavaScript se ejecuta en un único hilo. Qué pasa a continuación lo decide el event loop: toma una tarea de una cola, la ejecuta hasta el final, procesa todas las microtareas y después, si toca dibujar una imagen nueva, da al navegador la oportunidad de renderizar.

Una vuelta del event loop: una tarea, luego todas las microtareas y después, si toca una imagen nueva, requestAnimationFrame y el renderizado. A 60 Hz eso pasa más o menos cada 16,7 ms, así que muchas vueltas no renderizan nada.

Un pequeño experimento

console.log('1: síncrono');
setTimeout(() => console.log('5: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: Promise'));
queueMicrotask(() => console.log('4: queueMicrotask'));
console.log('2: síncrono');
La salida en la consola, exactamente en este orden y en cualquier navegador.

Primero se ejecuta todo el código síncrono, porque forma parte de la tarea actual. Después el event loop vacía por completo la cola de microtareas: callbacks de promesas y queueMicrotask, en el orden en que se encolaron. Solo entonces le llega el turno a la siguiente tarea, aquí el temporizador.

¿Y requestAnimationFrame?

requestAnimationFrame no pertenece a las tareas, sino al renderizado: el callback se ejecuta justo antes del siguiente paso de Style y Layout. Que eso ocurra antes o después de un setTimeout(…, 0) depende de cuándo quiera dibujar el navegador la siguiente imagen: a veces así, a veces al revés. Quien dependa de ese orden tiene una condición de carrera.

De ahí sale una regla útil: todo lo que cambia lo que se ve va en requestAnimationFrame; lo demás, no. Por cierto, en una pestaña en segundo plano rAF no se llama nunca.

Cuando las microtareas no terminan

Como la cola de microtareas se vacía por completo antes de que ocurra cualquier otra cosa, puede congelar la página:

function again() {
  queueMicrotask(again); // se vuelve a encolar una y otra vez
}
again(); // ni renderizado, ni clics, nada más

Con setTimeout en lugar de queueMicrotask, el mismo bucle sería inofensivo, porque entre dos tareas el navegador puede renderizar y atender entradas. Lo mismo pasa, de forma más sutil, con largas cadenas de await sobre promesas ya resueltas: parecen asíncronas, pero nunca liberan el hilo principal.

Los temporizadores no son exactos

setTimeout(fn, 0) no significa «ya», sino «como tarea propia, como muy pronto ahora». Con temporizadores muy anidados el navegador impone al menos 4 ms de separación, y en pestañas en segundo plano los temporizadores a menudo solo se ejecutan una vez por segundo o menos. Para soltar un momento el hilo principal, scheduler.yield() es mejor opción donde exista (ver Encontrar tareas largas antes que tus usuarios); para trabajo que puede esperar está scheduler.postTask() con prioridad:

if (globalThis.scheduler?.postTask) {
  scheduler.postTask(sendStats, { priority: 'background' }); // puede esperar
} else {
  setTimeout(sendStats, 0);
}

← Todas las notas