El Event Loop en JavaScript: por qué el código síncrono siempre va primero

En esta página
Enterate del próximo post
Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.

Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.
Una función pasada a setTimeout con retraso 0 debería, en apariencia, ejecutarse de inmediato. En la práctica nunca es lo primero que corre. La razón no tiene que ver con el número que se le pasa, sino con qué hace el motor con el código que todavía no puede ejecutar.
El ejemplo más simple alcanza para ver el comportamiento completo.
console.log("uno");
setTimeout(() => {
console.log("dos");
}, 0);
console.log("tres");
// uno
// tres
// dosAunque setTimeout recibe un retraso de 0 milisegundos, "dos" se imprime último. El motor no interrumpe la ejecución para atenderlo apenas se lo llama: termina de correr todo el código que ya está en curso -en este caso, hasta console.log("tres")- antes de tocar la función que quedó programada. El retraso, incluso siendo 0, no significa "ahora"; significa "después de que no quede nada síncrono corriendo".
Esto conecta directo con el Call Stack: mientras haya una llamada en la pila, el motor no puede empezar otra cosa. setTimeout no fuerza ninguna excepción a esa regla, solo programa algo para después.
La función que se le pasa a setTimeout no se queda esperando dentro de la pila de llamadas.

Cuando se llama a setTimeout, el motor le entrega la función y el tiempo de espera a una parte del entorno de ejecución (el navegador o Node, no el motor de JavaScript en sí) que se encarga de contar ese tiempo por fuera de la pila. Mientras ese contador corre, la pila sigue libre para seguir ejecutando el resto del código síncrono, como console.log("tres") en el ejemplo anterior.
Cuando el tiempo se cumple, la función no se ejecuta todavía: se coloca en una fila de espera, la cola de tareas. Ahí queda hasta que se cumple una condición concreta, que la pila de llamadas esté completamente vacía. El Event Loop es justamente eso: un mecanismo que revisa todo el tiempo la pila y, apenas la encuentra vacía, toma la primera función de la cola y la empuja a la pila para ejecutarla. En el ejemplo anterior, eso pasa recién después de que console.log("tres") terminó y no quedó ninguna llamada pendiente.
Cuando dos funciones llegan a la cola con el mismo retraso, el orden en el que se ejecutan no es arbitrario.
setTimeout(() => console.log("primero"), 0);
setTimeout(() => console.log("segundo"), 0);
console.log("sync");
// sync
// primero
// segundoLas dos funciones se programan con el mismo retraso (0), así que llegan a la cola una detrás de la otra, en el mismo orden en que se llamó a setTimeout. El Event Loop las saca de la cola en ese mismo orden: la que entró primero, sale primero. La cola de tareas se comporta como cualquier fila real: no hay forma de que la segunda función adelante a la primera solo por compartir el mismo tiempo de espera.
El número que recibe setTimeout tampoco define una posición fija en una fila común a todas las llamadas: hay que sumarle el tiempo real que falta para que se cumpla.
setTimeout(() => console.log("50 ms"), 50);
setTimeout(() => console.log("10 ms"), 10);
console.log("sync");
// sync
// 10 ms
// 50 msAcá la función con 10 milisegundos de retraso se programó después que la de 50, pero corre primero. El orden entre tareas en espera depende del tiempo que falta para que se cumpla su temporizador, no del orden en que se llamó a setTimeout. Cada función tiene su propio contador corriendo en el entorno de ejecución, y entra a la cola de tareas recién cuando ese contador llega a cero.
El retraso funciona como un piso, no como una promesa de instante exacto: la función no puede ejecutarse antes de que pasen esos milisegundos, pero sí puede tardar más si la pila sigue ocupada cuando el temporizador se cumple, como ya se vio en la primera sección con el retraso de 0.
El motor nunca corre dos cosas a la vez: termina todo el código síncrono y recién después atiende, una por una, las funciones que quedaron esperando en la cola de tareas. El número que recibe
setTimeoutes un tiempo mínimo de espera, no una garantía de ejecución inmediata.
El próximo post retoma setTimeout para mirar de cerca cómo funciona por dentro, junto con su parienta pensada para animaciones, requestAnimationFrame, ahora que ya quedó claro el mecanismo que decide cuándo corre cualquier función que no es inmediata.