Un solo hilo, pero no secuencial
JavaScript se ejecuta sobre un único hilo. Hay un solo call stack y, en cualquier instante, solo se está ejecutando una función. Dentro de un mismo realm, jamás vas a tener dos líneas de tu código corriendo en paralelo.
Suena limitante hasta que te paras a pensar qué hace JavaScript en realidad: pedir datos, esperar clics del usuario, leer ficheros. La mayor parte del "trabajo" es esperar. Ahí es donde entra el event loop, el truco que hace que esa espera salga barata: tu código le pasa la tarea al navegador o a Node, sigue haciendo otras cosas y recibe un aviso cuando el resultado está listo.
primero y segundo se imprimen en orden. tercero aparece después, aunque el timeout sea 0. Ese pequeño desfase es el event loop en acción, y entender por qué ocurre es justo lo que te quiero explicar en esta página.
El call stack en JavaScript
Cada llamada a una función mete un frame en el call stack. Cuando la función retorna, ese frame se saca. El stack funciona como cualquier pila: último en entrar, primero en salir.
Cuando se ejecuta outer(), Node mete outer en la pila, luego inner, saca inner cuando este retorna "listo" y después saca outer. La pila vuelve a estar vacía. Ese instante en que "la pila queda vacía" es justo lo que el event loop está esperando.
El código síncrono se ejecuta de principio a fin sobre la call stack y nada asíncrono puede interrumpirlo. Si escribes un while (true), la pila nunca se vacía y la página se congela: no hay clicks, ni timers, ni callbacks de promesas. El event loop se queda sin nada que hacer porque nunca le llega su turno.
Dónde vive realmente el trabajo asíncrono
JavaScript, por sí solo, no sabe hacer una petición de red ni esperar 100 milisegundos. Esas APIs son del entorno anfitrión: el navegador o Node. Cuando llamas a setTimeout(fn, 100), esto es lo que pasa:
- El temporizador queda registrado en el host.
setTimeoutretorna de inmediato y la pila sigue ejecutándose.- Pasados los 100ms, el host encola
fn. - Cuando la pila está vacía, el event loop saca
fnde la cola y lo ejecuta.
El callback del timer no puede ejecutarse hasta que terminen el bucle for y el console.log("fin"), porque el stack todavía no está vacío. Los timers marcan un retardo mínimo, no una garantía.
Las dos colas: tasks y microtasks
No hay una sola cola, hay dos. Y esa diferencia explica la mayoría de las sorpresas del event loop.
- Task queue (también llamada macrotask queue):
setTimeout,setInterval, callbacks de I/O, eventos de UI. - Microtask queue: callbacks de promesas (
.then,.catch,.finally), continuaciones deawaity cualquier cosa agendada conqueueMicrotask.
La regla que sigue el event loop es esta:
- Ejecuta una task de la task queue.
- Vacía por completo la microtask queue: cada microtask, incluidas las que se agreguen mientras se está vaciando.
- Renderiza si hace falta (en navegadores).
- Vuelve al paso 1.
Las microtasks siempre corren antes que la siguiente task. Por eso el siguiente ejemplo sorprende a más de uno:
Orden de salida: sync 1, sync 2, promesa, timeout. Primero se ejecuta el código síncrono. Luego el call stack se vacía. Después el event loop procesa todos los microtasks (promesa). Y recién ahí toma la tarea del timer (timeout).
Los microtasks pueden dejar sin ejecución a las tasks
Como la cola de microtasks se vacía por completo antes de pasar a la siguiente task, un microtask que no para de agendar más microtasks bloqueará la task queue de forma indefinida:
El temporizador no se dispararía nunca, porque cada microtarea encola otra microtarea y el loop jamás deja que la cola se vacíe. Las cadenas de promesas están a salvo porque cada .then solo programa una continuación, pero los bucles de microtareas hechos a mano son una trampa que conviene tener presente.
await es azúcar sintáctico para una microtask
Cuando haces await sobre una promesa, la función se pausa y el resto queda programado como microtarea para ejecutarse cuando la promesa se resuelva. No hay magia: por debajo es simplemente un .then.
Salida: A, C, B. El await devuelve el control a quien llamó a la función. console.log("C") se ejecuta en el stack actual. Después se vacía la microtask queue y se reanuda el resto de demo, imprimiendo B.
Ten esto presente cuando leas código asíncrono. await no bloquea: cede el control.
Un ejemplo completo: ordenando todas las piezas
Juntemos todo en un solo ejemplo:
Orden de ejecución:
1: sync— se ejecuta en el stack.6: sync— sigue en el stack.- El stack se vacía. Se drena la cola de microtareas:
3: promise,5: microtasky luego4: nested microtask(se agendó mientras se drenaba, pero aun así se toma en esta ronda). - Se ejecuta la siguiente tarea:
2: timeout.
Salida final: 1, 6, 3, 5, 4, 2. Si puedes seguir este trazado, ya entiendes cómo funciona el event loop.
Node tiene más fases
El event loop de Node es un superconjunto del modelo del navegador. Tiene fases bien diferenciadas — timers, pending I/O callbacks, poll, check, close — y las microtareas se drenan entre cada fase. setImmediate corre en la fase check, mientras que process.nextTick se ejecuta antes que las microtareas normales (tiene su propia cola con prioridad aún mayor).
No hace falta que memorices el diagrama de fases el primer día. La idea clave es la misma que en el navegador: el código síncrono corre hasta terminar, luego se drenan las microtareas y, recién ahí, el loop toma el siguiente callback encolado.
Por qué importa
Cuando este modelo te hace clic, mucho del código asíncrono deja de ser un misterio:
- Un
forlargo congela la UI porque el event loop no puede tomar su turno. setTimeout(fn, 0)es una forma de diferir trabajo hasta después de que termine la tarea actual y se drenen las microtareas.- Un callback de
.thenque "se ejecuta inmediatamente" sobre una promesa ya resuelta igual espera a que termine el código síncrono actual. - Un
awaitdentro de un bucle serializa el trabajo, porque cada iteración cede a la cola de microtareas antes de continuar.
Depurar código asíncrono consiste básicamente en preguntarse: "¿qué hay ahora mismo en el call stack y qué está encolado?". El event loop es la respuesta.
Lo que sigue: callbacks
Antes de las promesas y de async/await, la única herramienta de JavaScript para trabajo asíncrono era el callback — una función que le pasas a una API para que la llame más tarde. Los callbacks siguen estando en todos lados (event listeners, APIs nativas de Node), y entenderlos es la base de todo lo que viene en este capítulo.
Preguntas frecuentes
¿Qué es el event loop en JavaScript?
Es el mecanismo que permite a JavaScript (que es single-threaded) ejecutar trabajo asíncrono sin bloquearse. El loop vigila la call stack: cuando está vacía, saca el siguiente callback encolado y lo ejecuta. Los timers, la E/S y las continuaciones de promesas acaban todos en colas que el event loop va vaciando de una en una.
¿Por qué JavaScript es single-threaded?
La especificación del lenguaje define una única call stack por realm, así que tu código corre en un solo hilo. La concurrencia viene del host (navegador o Node), que delega el trabajo a APIs en segundo plano —timers, red, E/S de ficheros— y encola un callback cuando terminan. Nunca vas a ver dos trozos de JS ejecutándose a la vez en el mismo contexto.
¿Cuál es la diferencia entre microtareas y macrotareas?
Las microtareas vienen de las promesas (.then, await) y de queueMicrotask. Las macrotareas salen de setTimeout, setInterval, E/S y eventos de UI. Cuando termina cada macrotarea, el event loop vacía por completo la cola de microtareas antes de pasar a la siguiente macrotarea. Por eso un Promise.resolve().then(...) siempre se ejecuta antes que un setTimeout(..., 0) programado en el mismo instante.
¿Por qué un setTimeout con 0ms no se ejecuta de inmediato?
setTimeout(fn, 0) no significa «ejecuta ya», significa «encola fn como macrotarea, como muy pronto pasados 0ms». Primero tiene que acabar el código síncrono actual, después se vacía la cola de microtareas y solo entonces el event loop coge tu callback del timer. O sea, ese 0 es un mínimo, no una garantía.