Menu

Event Loop en JavaScript: cómo funciona el código async

El modelo mental del JavaScript asíncrono: la call stack, la cola de tareas, la cola de microtareas y cómo el event loop lo coordina todo.

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.

index.js
Output
Click Run to see the output here.

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.

index.js
Output
Click Run to see the output here.

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:

  1. El temporizador queda registrado en el host.
  2. setTimeout retorna de inmediato y la pila sigue ejecutándose.
  3. Pasados los 100ms, el host encola fn.
  4. Cuando la pila está vacía, el event loop saca fn de la cola y lo ejecuta.
index.js
Output
Click Run to see the output here.

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 de await y cualquier cosa agendada con queueMicrotask.

La regla que sigue el event loop es esta:

  1. Ejecuta una task de la task queue.
  2. Vacía por completo la microtask queue: cada microtask, incluidas las que se agreguen mientras se está vaciando.
  3. Renderiza si hace falta (en navegadores).
  4. Vuelve al paso 1.

Las microtasks siempre corren antes que la siguiente task. Por eso el siguiente ejemplo sorprende a más de uno:

index.js
Output
Click Run to see the output here.

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:

index.js
Output
Click Run to see the output here.

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.

index.js
Output
Click Run to see the output here.

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:

index.js
Output
Click Run to see the output here.

Orden de ejecución:

  1. 1: sync — se ejecuta en el stack.
  2. 6: sync — sigue en el stack.
  3. El stack se vacía. Se drena la cola de microtareas: 3: promise, 5: microtask y luego 4: nested microtask (se agendó mientras se drenaba, pero aun así se toma en esta ronda).
  4. 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 for largo 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 .then que "se ejecuta inmediatamente" sobre una promesa ya resuelta igual espera a que termine el código síncrono actual.
  • Un await dentro 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.

Aprende a programar con Coddy

COMENZAR