Event Loop de JavaScript explicado: microtareas, tareas y Node
Introducción
JavaScript ejecuta tu código en un único hilo. Hay un solo call stack, y en cada momento solo se ejecuta una función. Aun así, una pestaña del navegador sigue respondiendo a los clics mientras espera una petición de red, y un único proceso de Node.js atiende miles de conexiones concurrentes.
¿Cómo puede un lenguaje de un solo hilo ser tan bueno haciendo muchas cosas a la vez? La respuesta es el event loop (bucle de eventos), el mecanismo que permite al JavaScript asíncrono esperar temporizadores, respuestas de red y la interacción del usuario sin bloquearse.
Entender el event loop explica la mayoría de los comportamientos "raros" que te encontrarás con el código asíncrono: por qué setTimeout(fn, 0) no se ejecuta inmediatamente, por qué el callback de una Promise se ejecuta antes que un temporizador programado primero, por qué un bucle largo congela la página y por qué process.nextTick y setImmediate tienen nombres tan confusos.
En este post, te explico cómo se organiza el runtime de JavaScript, recorro el algoritmo del event loop paso a paso, comparo las microtareas y las macrotareas, y repaso las particularidades del event loop de Node.js. Termino con ejemplos reales de mis propios proyectos de código libre, errores comunes, consejos de depuración y algunas preguntas clásicas de entrevista.
¿Qué es el event loop de JavaScript?
El event loop de JavaScript es el mecanismo del runtime que ejecuta tu código y después espera el trabajo encolado y lo procesa elemento a elemento. Cada vez que el call stack está vacío, el event loop toma la siguiente tarea de una cola, la ejecuta hasta completarse y después ejecuta todas las microtareas pendientes antes de continuar.
Esa es toda la idea. Todo lo demás en este post es un detalle de lo que significan "una tarea", "una microtarea" y "la cola", y de en qué se diferencian el navegador y Node.js.
El ejemplo más pequeño que muestra el event loop en acción:
console.log('1. Start');
setTimeout(() => console.log('3. Timeout callback'), 0);
console.log('2. End');1. Start
2. End
3. Timeout callbackIncluso con un retardo de cero, el callback del temporizador se ejecuta el último.
setTimeout no llama a la función; le pide al entorno anfitrión que la encole cuando el temporizador expire.
El event loop solo puede recoger ese callback cuando el script actual termina y el call stack está vacío.
El runtime de JavaScript: call stack, heap, colas y APIs del anfitrión
Un error muy común es pensar que el event loop forma parte del motor de JavaScript (V8, SpiderMonkey, JavaScriptCore). No es así. La especificación de ECMAScript solo define jobs (los jobs de las Promises que los navegadores llaman microtareas) y dice que se ejecutan "cuando no hay ningún otro cálculo de ECMAScript en curso". El bucle en sí pertenece al anfitrión (host): el navegador lo implementa siguiendo la especificación de HTML, y Node.js lo implementa sobre libuv.
El runtime de JavaScript se compone de estas piezas:
- Call stack (pila de llamadas): donde se ejecuta tu código, con un frame por cada llamada a función.
- Heap: la región de memoria no estructurada donde viven los objetos, los closures y los arrays.
- APIs del anfitrión: temporizadores, red, eventos del DOM y el sistema de ficheros, que proporcionan las Web APIs en el navegador y los bindings de C++ apoyados en libuv en Node.js.
- Colas de tareas (task queues): callbacks listos para ejecutarse, como un temporizador que ha expirado, un evento de clic o la lectura de un fichero que ha terminado.
- Cola de microtareas (microtask queue): callbacks de mayor prioridad, principalmente las reacciones de las Promises y las continuaciones de
await. - El event loop: el coordinador que lleva el trabajo de las colas al call stack.
"JavaScript es de un solo hilo" solo es cierto para tu código. El runtime utiliza muchos hilos por debajo: el navegador tiene hilos de red y de composición, Node.js tiene un thread pool para las operaciones del sistema de ficheros, y ambos motores ejecutan la recolección de basura y la compilación en paralelo. Lo que obtienes es un único hilo ejecutando JavaScript por cada event loop, y por eso nunca necesitas locks para actualizar una variable.
Nota
Los Web Workers en el navegador y worker_threads en Node.js te dan hilos de JavaScript adicionales.
Cada worker tiene su propio call stack, heap y event loop, y se comunican intercambiando mensajes.
El call stack
El call stack es una estructura de tipo último en entrar, primero en salir (LIFO) que registra en qué punto está el programa.
Cada llamada a una función apila un stack frame con sus argumentos y variables locales; cada return lo desapila.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
console.log(square(n));
}
printSquare(4);Cuando se ejecuta este script, la pila crece y decrece así:
- El propio script empieza como el frame de la base.
- Se apila
printSquare(4). - Se apila
square(4)encima. - Se apila
multiply(4, 4), que devuelve16y se desapila. squaredevuelve16y se desapila.- Se apila
console.log(16), que imprime16y se desapila. printSquareretorna y, finalmente, se desapila el frame del script.
La pila está ahora vacía, y ese es justo el momento que espera el event loop. Esto es también lo que te muestra el stack trace de un error: una instantánea de los frames del call stack cuando se creó el error.
Ejecución hasta completarse (run to completion)
Cuando una función empieza a ejecutarse, nada puede interrumpirla. Ningún temporizador, manejador de clics o callback de red se ejecuta hasta que el call stack vuelve a estar vacío. Esta garantía se llama run to completion (ejecución hasta completarse), y es lo que hace que el código JavaScript sea fácil de razonar: el estado que lees al principio de una función sigue siendo el mismo al final, salvo que tu propio código lo haya cambiado.
La otra cara de la moneda es que una función lenta lo bloquea todo:
const start = Date.now();
setTimeout(() => {
console.log(`Timeout fired after ${Date.now() - start} ms`);
}, 0);
// Simulate 1 second of CPU-bound work
while (Date.now() - start < 1000) {
// busy wait
}
console.log('Synchronous work done');Synchronous work done
Timeout fired after 1004 msEl temporizador expiró a los pocos milisegundos, pero su callback tuvo que esperar un segundo entero a que terminara el bucle. En un navegador, ese mismo segundo es un segundo sin clics, sin scroll y sin repintados.
Stack overflow
La pila tiene un tamaño limitado. Una función recursiva sin caso base sigue apilando frames hasta que el motor se rinde:
function countDown(n) {
return countDown(n - 1); // No base case
}
try {
countDown(10);
} catch (error) {
console.log(error.name, error.message);
}RangeError Maximum call stack size exceededV8 (Chrome, Node.js) y JavaScriptCore (Safari) lanzan un RangeError con este mensaje, mientras que Firefox lanza InternalError: too much recursion.
La profundidad máxima no está estandarizada; depende del motor, de la plataforma y del tamaño de cada frame.
Cómo funciona el event loop, paso a paso
La especificación de HTML describe el event loop del navegador como un bucle infinito. De forma simplificada, cada iteración hace lo siguiente:
- Elegir una tarea: escoge una cola de tareas que tenga una tarea ejecutable y toma su tarea más antigua.
- Ejecutar la tarea hasta completarse: ejecuta su callback hasta que el call stack quede vacío.
- Realizar un microtask checkpoint: ejecuta todas las microtareas de la cola de microtareas, incluidas las que encolan otras microtareas, hasta que la cola quede vacía.
- Actualizar el renderizado, si toca: ejecuta los callbacks de
requestAnimationFrame, recalcula estilos y layout, y pinta. - Esperar: si no hay ninguna tarea que ejecutar, duerme hasta que llegue una nueva (ejecutando los callbacks de
requestIdleCallbackmientras está inactivo). - Repetir.
Hay algunos detalles que merece la pena conocer, porque las explicaciones simplificadas suelen omitirlos.
No hay una única cola de callbacks. Cada tarea procede de una fuente de tareas (task source), como los temporizadores, la interacción del usuario, la red o la manipulación del DOM, y un navegador mantiene varias colas de tareas, cada una alimentada por una o varias de esas fuentes. Las tareas de una misma fuente siempre se ejecutan en orden, pero el navegador puede elegir qué cola atender a continuación. La propia especificación pone el ejemplo de una cola para los eventos de ratón y teclado, atendida antes que otra cola para todo lo demás, así que un clic puede atenderse antes que un temporizador que expiró antes.
El renderizado no ocurre después de cada tarea. El navegador solo renderiza cuando se le presenta una rendering opportunity, que suele coincidir con la frecuencia de refresco de la pantalla (aproximadamente cada 16,7 ms a 60 Hz), y en una pestaña oculta, por lo general, no renderiza en absoluto. Entre dos frames pueden ejecutarse muchas tareas, y un frame se omite cuando no hay nada visible que actualizar. El renderizado ocurre entre tareas, nunca en mitad de una.
Microtareas vs macrotareas
Encontrarás el término macrotarea (macrotask) en la mayoría de artículos y preguntas de entrevista, pero la especificación de HTML nunca lo utiliza. La especificación simplemente las llama tareas (tasks), en contraposición a las microtareas (microtasks). A partir de aquí hablaré de "tareas"; ten en cuenta que ambos términos significan lo mismo.
La diferencia entre ellas es cuándo se ejecutan:
- Las tareas se ejecutan de una en una por cada iteración del event loop, con la posibilidad de renderizar y ejecutar otras tareas entre medias.
- Las microtareas se ejecutan en cuanto el call stack está vacío, y la cola se vacía por completo antes de que ocurra cualquier otra cosa: ni otra tarea ni renderizado.
| API | Se ejecuta como | Entorno |
|---|---|---|
setTimeout, setInterval | Tarea | Navegador y Node.js |
Eventos de UI (click, keydown), postMessage | Tarea | Navegador |
Callbacks de I/O (fs, sockets) | Tarea (fase poll) | Node.js |
setImmediate | Tarea (fase check) | Node.js |
Promise.then, catch, finally | Microtarea | Navegador y Node.js |
Continuaciones de await | Microtarea | Navegador y Node.js |
queueMicrotask | Microtarea | Navegador y Node.js |
MutationObserver | Microtarea | Navegador |
process.nextTick | Cola next tick, antes de las microtareas | Node.js |
requestAnimationFrame | Paso de renderizado, antes de pintar | Navegador |
Las filas de Node.js se explican en la sección de Node.js. Así se ve la prioridad en acción:
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
queueMicrotask(() => console.log('queueMicrotask'));
console.log('script end');script start
script end
promise 1
queueMicrotask
promise 2
setTimeoutEl código síncrono se ejecuta primero, ya que el script es la tarea actual.
Después, la cola de microtareas contiene promise 1 y queueMicrotask, en el orden en que se encolaron.
Al ejecutar promise 1 se resuelve la siguiente promesa de la cadena, lo que encola promise 2 al final de la cola de microtareas, así que se ejecuta antes de que termine el checkpoint.
Solo entonces puede el event loop tomar la tarea del temporizador.
Las microtareas ni siquiera esperan al final de la tarea.
Un microtask checkpoint ocurre cada vez que el call stack de JavaScript queda vacío.
Si haces clic en un botón con dos listeners de click, el navegador llama a cada listener por separado, y las microtareas que encola el primero se ejecutan antes de que empiece el segundo.
Si, en cambio, tu código llama a button.click(), ambos listeners se ejecutan dentro de tu llamada, la pila nunca se vacía entre medias y sus microtareas esperan a que termine tu script.
Cómo utiliza async/await la cola de microtareas
async/await es sintaxis construida sobre las Promises, así que sigue las mismas reglas.
Una función async se ejecuta de forma síncrona hasta su primer await.
En ese punto, la función se suspende, y el resto de su cuerpo se encola como una microtarea cuando el valor esperado se resuelve.
async function loadUser() {
console.log('loadUser: start');
await null;
console.log('loadUser: after await');
}
console.log('before');
loadUser();
console.log('after');before
loadUser: start
after
loadUser: after awaitLa función se comporta como si se hubiera escrito con un callback then:
function loadUser() {
console.log('loadUser: start');
return Promise.resolve(null).then(() => {
console.log('loadUser: after await');
});
}Esto responde a una pregunta habitual: ¿bloquea await?
Bloquea la función async, que no continúa hasta que el valor está listo, pero nunca bloquea el hilo.
Mientras la función está suspendida, el event loop sigue ejecutando otras tareas.
Esperar una Promise nativa cuesta un único tick de microtarea en los motores actuales.
Antes de V8 7.2 (Chrome 72, Node.js 12), costaba al menos tres ticks; el artículo del equipo de V8 explica el cambio en la especificación que eliminó los ticks adicionales.
Por ese motivo, los puzles de entrevista escritos antes de 2019 pueden dar una respuesta distinta para el código que mezcla await y then.
Inanición por microtareas (microtask starvation)
Como la cola de microtareas se vacía por completo, una microtarea que no deja de encolar más microtareas nunca deja avanzar al event loop:
const start = performance.now();
setTimeout(() => {
console.log(`setTimeout ran after ${Math.round(performance.now() - start)} ms`);
}, 0);
const spin = () => {
// Keep queueing microtasks for 200 ms
if (performance.now() - start < 200) {
queueMicrotask(spin);
}
};
queueMicrotask(spin);setTimeout ran after 200 msTuve que limitar el bucle a 200 ms para que terminara.
Sin esa condición, el temporizador nunca se dispararía y, en un navegador, la página nunca se repintaría.
Una cadena recursiva de Promises es tan peligrosa como un bucle while (true).
setTimeout(fn, 0) y el clamping de temporizadores
El retardo que le pasas a setTimeout es un mínimo, no una garantía de cuándo se ejecutará.
Cuando el temporizador expira, el callback se encola como una tarea, y todavía tiene que esperar a la tarea actual, a todas las microtareas, posiblemente a una actualización del renderizado y a cualquier otra tarea que el navegador decida ejecutar antes.
Además, ambos entornos ajustan los retardos pequeños:
- Los navegadores limitan los temporizadores anidados: cuando los temporizadores se anidan más de cinco niveles, la especificación de HTML convierte cualquier retardo inferior a 4 ms en 4 ms, para que los temporizadores recursivos mal escritos no puedan quemar la CPU.
- Las pestañas en segundo plano sufren throttling: Chrome ejecuta los temporizadores de una página oculta como mucho una vez por segundo, y tras cinco minutos en segundo plano puede limitar los temporizadores encadenados a una vez por minuto.
- Node.js tiene un mínimo de 1 ms: cualquier retardo inferior a 1 ms (o superior a 2147483647 ms, que desborda un entero de 32 bits) pasa a ser 1 ms, y no hay límite de 4 ms.
Puedes ver el clamping midiendo el intervalo entre temporizadores anidados:
const gaps = [];
let last = performance.now();
const tick = () => {
const now = performance.now();
gaps.push((now - last).toFixed(1));
last = now;
if (gaps.length < 10) {
setTimeout(tick, 0);
} else {
console.log(gaps.join(', '));
}
};
setTimeout(tick, 0);0.1, 0.1, 0.0, 0.0, 0.0, 0.0, 4.5, 4.6, 4.6, 4.1Los seis primeros callbacks se ejecutaron uno detrás de otro; a partir del séptimo, cada uno esperó al menos 4 ms.
Entonces, ¿para qué sirve setTimeout(fn, 0)?
Es la forma clásica de aplazar trabajo a una tarea posterior: dejar que el código actual termine, darle al navegador la ocasión de procesar los eventos pendientes (y de repintar, si toca un nuevo frame) y continuar después.
También es la forma de dividir un trabajo largo en fragmentos para que la página siga respondiendo, como verás en la sección de errores comunes.
Si solo necesitas ejecutar algo después del código actual pero antes que cualquier otra cosa, utiliza queueMicrotask.
Renderizado y requestAnimationFrame
El event loop del navegador también se encarga del renderizado, que es la principal diferencia con el event loop de Node.js. Cuando llega el momento de un nuevo frame, el navegador ejecuta estos pasos en orden:
- Eventos de redimensionado y de scroll.
- Actualizaciones de media queries y animaciones.
- Callbacks de
requestAnimationFrame. - Cálculo de estilos y layout, incluidos los callbacks de
ResizeObserver. - Callbacks de
IntersectionObserver. - Pintado.
requestAnimationFrame (rAF) te permite ejecutar código justo antes del siguiente pintado, lo que lo convierte en la herramienta adecuada para las animaciones:
const box = document.querySelector('.box');
let start;
function step(timestamp) {
start ??= timestamp;
const elapsed = timestamp - start;
box.style.transform = `translateX(${Math.min(0.1 * elapsed, 200)}px)`;
if (elapsed < 2000) {
requestAnimationFrame(step); // Runs in the next frame
}
}
requestAnimationFrame(step);Algunos detalles sobre rAF:
- Un callback programado desde dentro de un callback de rAF se ejecuta en el siguiente frame, así que el patrón recursivo anterior se ejecuta exactamente una vez por frame.
- Las microtareas encoladas desde un callback de rAF se ejecutan justo después de ese callback, antes del siguiente.
- El navegador pausa o limita rAF en las pestañas ocultas, para que una animación no gaste batería cuando nadie la está viendo.
- Utiliza el argumento
timestamppara calcular el progreso en lugar de asumir una tasa de frames fija: las pantallas refrescan a 60, 120 o 144 Hz.
Animar con setTimeout(step, 16) no está sincronizado con la pantalla.
Algunos frames reciben dos actualizaciones y otros ninguna, lo que se nota como tirones (jank).
Y no confíes en el orden entre un setTimeout(fn, 0) y un callback de requestAnimationFrame programados a la vez: depende de en qué punto de su frame esté el navegador.
Los navegadores tampoco se han puesto siempre de acuerdo; Safari ejecutaba los callbacks de rAF después de pintar en lugar de antes hasta que WebKit lo corrigió en 2019.
El event loop de Node.js
Node.js no renderiza nada, y su event loop está organizado de otra forma. Lo proporciona libuv, una librería de C que encapsula los mecanismos de I/O asíncrona del sistema operativo (epoll en Linux, kqueue en macOS, IOCP en Windows). En lugar de un conjunto de colas de tareas entre las que elige el bucle, Node.js recorre una secuencia fija de fases, cada una con su propia cola de callbacks.
Fases del event loop de Node.js
Desde Node.js 20.3, cada iteración del bucle recorre estas fases en este orden (la nota que sigue al diagrama explica qué cambió):
| Fase | Qué se ejecuta en ella |
|---|---|
| Pending callbacks | Callbacks de I/O aplazados desde la iteración anterior, como algunos errores de TCP |
| Idle, prepare | Solo uso interno |
| Poll | Nuevos eventos de I/O y sus callbacks; el bucle se bloquea aquí cuando no hay nada más que hacer |
| Check | Callbacks de setImmediate |
| Close callbacks | Eventos de cierre, como socket.on('close', ...) |
| Timers | Callbacks de setTimeout y setInterval que han expirado (también una vez antes de la primera iteración) |
Nota
A partir de libuv 1.45 (Node.js 20.3), los temporizadores se ejecutan al final de cada iteración, después de las fases poll, check y close.
En versiones anteriores se ejecutaban al principio, antes del polling.
libuv sigue ejecutando los temporizadores una vez antes de entrar en el bucle, así que el ciclo parece el mismo, pero el cambio puede afectar al orden relativo de los callbacks de setImmediate y de los temporizadores en algunos escenarios.
Muchos diagramas, incluido el de la documentación de Node.js, siguen mostrando los temporizadores como la primera fase.
La fase poll es donde Node.js pasa la mayor parte de su tiempo de inactividad.
Si no hay callbacks de setImmediate esperando, se bloquea esperando I/O, pero solo hasta que vence el temporizador más próximo.
Cuando no queda nada (ni temporizadores, ni sockets abiertos, ni peticiones pendientes), el bucle termina y el proceso finaliza.
Entre cada callback, en todas las fases, Node.js vacía dos colas: primero la cola de process.nextTick y después la cola de microtareas de las Promises.
process.nextTick vs queueMicrotask
process.nextTick es anterior a las Promises en Node.js.
Sus callbacks van a una cola específica que se procesa después de la operación actual y antes de la cola de microtareas de las Promises.
A pesar de su nombre, no espera a que el bucle avance: sus callbacks se ejecutan en cuanto termina la operación actual.
Quien espera es setImmediate, hasta la siguiente fase check, y la propia documentación de Node.js reconoce que los dos nombres deberían estar intercambiados.
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');sync
nextTick
promise
queueMicrotask
setTimeout
setImmediateGuarda el mismo código como node-order.mjs y el orden de las microtareas cambia:
sync
promise
queueMicrotask
nextTick
setTimeout
setImmediateUn módulo ES se evalúa como parte de la cola de microtareas.
Cuando el cuerpo del módulo termina, Node.js sigue en mitad del vaciado de las microtareas, así que los callbacks de las Promises se ejecutan primero, y la cola de nextTick solo se procesa cuando la cola de microtareas está vacía.
Las llamadas recursivas a process.nextTick provocan la inanición del event loop exactamente igual que las microtareas recursivas, impidiendo que llegue nunca a la fase poll.
Desde Node.js 22.7 (y 20.18), la documentación marca process.nextTick como legacy y recomienda queueMicrotask en su lugar, que se comporta igual en los navegadores y en Node.js.
setImmediate vs setTimeout
En el ejemplo anterior, setTimeout se ejecutó antes que setImmediate, pero no puedes confiar en ello.
Desde el módulo principal, su orden es no determinista: depende de cuánto tiempo haya pasado cuando arranca el bucle.
En mis pruebas con Node.js 26, un script que solo programaba los dos ejecutó setImmediate primero 96 veces de cada 100, mientras que el script anterior, que hace algo más de trabajo antes de que arranque el bucle, ejecutó el temporizador primero todas las veces.
Dentro de un callback de I/O, sin embargo, el orden está garantizado:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});setImmediate
setTimeoutEl callback de readFile se ejecuta en la fase poll.
La siguiente fase es check, así que el callback de setImmediate siempre se ejecuta antes de que el bucle vuelva a los temporizadores.
Utiliza setImmediate cuando quieras ejecutar algo después de los callbacks de I/O pendientes, por ejemplo para dividir un trabajo largo sin dejar sin atender las conexiones del servidor.
El thread pool de libuv
La I/O de red en Node.js utiliza las APIs no bloqueantes del sistema operativo directamente en el hilo del event loop.
Algunas operaciones no tienen un equivalente no bloqueante, así que libuv las ejecuta en un thread pool y encola sus callbacks cuando terminan: las APIs asíncronas del sistema de ficheros (salvo fs.watch, que se apoya en las notificaciones de cambios del sistema operativo), dns.lookup, algunas funciones de crypto (pbkdf2, scrypt, randomBytes, generateKeyPair) y las APIs asíncronas de zlib, tanto para comprimir como para descomprimir.
El pool tiene 4 hilos por defecto, algo que puedes comprobar con cinco hashes que consumen mucha CPU:
const crypto = require('node:crypto');
const start = Date.now();
for (let i = 1; i <= 5; i++) {
crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => {
console.log(`hash ${i} done after ${Date.now() - start} ms`);
});
}hash 1 done after 125 ms
hash 2 done after 132 ms
hash 4 done after 134 ms
hash 3 done after 143 ms
hash 5 done after 234 msLos cuatro primeros hashes se ejecutan en paralelo y terminan aproximadamente a la vez, en un orden distinto en cada ejecución.
El quinto tiene que esperar a que quede un hilo libre, así que tarda el doble.
Puedes cambiar el tamaño del pool con la variable de entorno UV_THREADPOOL_SIZE (hasta 1024), siempre que la establezcas antes de que el pool se utilice por primera vez.
El event loop en una aplicación real: ElectronIM
ElectronIM es un cliente de mensajería instantánea libre y de código abierto que mantengo y que combina tus aplicaciones web de mensajería (Slack, Telegram, WhatsApp o cualquier otra) en una única ventana de escritorio de Electron. Electron hace que el event loop sea especialmente visible porque una aplicación se compone de varios procesos: un proceso principal (main process) que ejecuta Node.js, y un proceso renderer por cada ventana o vista web, cada uno con el event loop de navegador de Chromium. Se comunican mediante mensajes IPC (comunicación entre procesos), que llegan como tareas al event loop del proceso que los recibe.
Cuando el orden de las tareas es el bug
ElectronIM proporciona su propio corrector ortográfico para los servicios. Tras actualizar a Electron 44, escribir en algunos servicios empezó a provocar que su renderer fallara y dejara una página en blanco. Blink, el motor de renderizado de Chromium, había empezado a cancelar sus peticiones de corrección ortográfica en curso:
- Blink le pide a ElectronIM que compruebe las palabras de la petición A.
- El usuario sigue escribiendo, así que Blink cancela A y emite la petición B.
- La respuesta a A llega, y el cliente de corrección ortográfica de Electron, que solo registra una petición pendiente, la aplica a B, así que la respuesta a B no encuentra después nada pendiente y hace que el renderer falle.
La pull request que lo corrigió gira por completo en torno al orden de las tareas.
La función de corrección ortográfica solo responde a la petición más reciente, y lo hace desde un requestIdleCallback:
let pendingRequest = null;
const spellCheckFunction = (words, callback) => {
const request = {};
pendingRequest = request;
const answer = misspelled => requestIdleCallback(() => {
if (pendingRequest !== request) {
return;
}
pendingRequest = null;
try {
callback(misspelled);
} catch (error) {
console.error('Spell check callback failed', error);
}
});
ipcRenderer.invoke(APP_EVENTS.dictionaryGetMisspelled, words)
.then(answer, () => answer([]));
};Electron entrega cada nueva petición a esta función en una tarea normal.
Así que, cuando una respuesta antigua está lista, puede que ya haya una petición más reciente encolada detrás.
Un idle callback solo se ejecuta cuando el event loop no tiene tareas pendientes, lo que significa que esa entrega encolada se ejecuta primero y reemplaza pendingRequest, y la respuesta obsoleta se descarta.
El script de preload del corrector ortográfico no le pasa a propósito ningún timeout a requestIdleCallback: un idle callback cuyo timeout ha vencido se ejecuta como una tarea normal, y podría adelantarse a la entrega.
Los diccionarios en sí se cargan en un proceso renderer oculto (src/spell-check/index.js), que actúa como un worker con su propio event loop.
Cargar los diccionarios de Hunspell lleva un tiempo, y hacerlo en el proceso principal congelaría todas las ventanas de la aplicación.
IPC asíncrono y síncrono
Electron ofrece tanto IPC asíncrono (ipcRenderer.send e ipcRenderer.invoke, que devuelve una Promise) como IPC síncrono (ipcRenderer.sendSync).
sendSync bloquea el event loop del renderer hasta que el proceso principal responde, así que deberías evitarlo.
ElectronIM lo utiliza en algunos sitios, y en uno de ellos no hay alternativa.
Reemplaza el constructor web Notification para que el usuario pueda desactivar las notificaciones por servicio, y un constructor tiene que devolver su resultado de forma síncrona:
const canNotify = () => ipcRenderer.sendSync(APP_EVENTS.canNotify, globalThis.tabId);
Notification = function() {
if (canNotify()) {
const delegate = new NativeNotification(...arguments);
// ...
}
return {};
};Temporizadores que no mantienen viva la aplicación
El proceso principal comprueba si hay nuevas versiones cada 30 minutos:
const checkForUpdatesInit = async () => {
await checkForUpdates();
setInterval(checkForUpdates, 1000 * 60 * 30).unref();
};En Node.js puro, un temporizador activo mantiene vivos el event loop y el proceso.
Llamar a unref() le indica a Node.js que este intervalo, por sí solo, no es motivo para seguir en ejecución.
El proceso principal de Electron termina con app.quit() o app.exit() de todas formas, pero el unref() evita que el módulo mantenga abierto un proceso de Node.js puro, como el que ejecuta sus tests unitarios.
Timeouts encadenados en una aplicación real: Isotope
Isotope, el cliente webmail que desarrollé con un front-end en React, muestra otro patrón de temporizadores.
Cada refresco periódico recarga las carpetas y recibe los mensajes de la carpeta mediante Server-Sent Events, cuyos callbacks de mensaje se encolan como tareas.
El siguiente refresco se programa con setTimeout solo cuando await Promise.all(...) se resuelve, en lugar de con un setInterval.
Así, dos refrescos nunca pueden solaparse, por muy lento que sea el servidor.
Errores comunes
La mayoría de los problemas con el event loop se reducen a trabajo que tarda demasiado en el único hilo que ejecuta tu código, o a callbacks que nunca desaparecen.
Bloquear el event loop
Como un solo hilo ejecuta todo tu JavaScript, cualquier operación síncrona larga bloquea todo lo demás. En un navegador, la página se congela; en Node.js, todas las demás peticiones esperan. Los sospechosos habituales son:
- Bucles que consumen mucha CPU: ordenar, filtrar o transformar arrays grandes, procesar imágenes o hacer criptografía en JavaScript.
- Payloads JSON enormes: la documentación de Node.js midió que
JSON.parsetardaba 1,3 segundos con una cadena de 50 MB. - APIs síncronas:
fs.readFileSync,crypto.pbkdf2Sync,zlib.gzipSyncochild_process.execSyncen un servidor. - Expresiones regulares catastróficas (ReDoS): un patrón con cuantificadores anidados como
/(a+)*$/puede tardar un tiempo exponencial con una entrada preparada.
Advertencia
En un servidor Node.js, una sola petición que dispare una expresión regular lenta o analice un payload enorme bloquea a todos los demás clientes. Valida el tamaño de las entradas antes de analizarlas y evita los cuantificadores anidados en las expresiones regulares que procesan datos del usuario.
Dividir las tareas largas
Si el trabajo tiene que hacerse en el hilo principal, divídelo en fragmentos y cede el control al event loop entre ellos, para que puedan ejecutarse otras tareas y el renderizado:
const yieldToEventLoop = () =>
globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise(resolve => setTimeout(resolve, 0));
async function processAll(items, processItem) {
let lastYield = performance.now();
for (const item of items) {
processItem(item);
if (performance.now() - lastYield > 50) {
await yieldToEventLoop(); // let other tasks (and rendering) run
lastYield = performance.now();
}
}
}scheduler.yield() es la forma moderna de ceder el control en el navegador: su continuación se ejecuta antes que otras tareas de la misma prioridad, así que tu trabajo no pierde su sitio en la cola.
Está disponible en Chrome 129 y Firefox 142, pero todavía no en Safari, de ahí la alternativa con setTimeout.
Node.js no tiene un scheduler global, así que allí la función recurre a setTimeout; es preferible await setImmediate() de node:timers/promises, que deja que se ejecuten primero los callbacks de I/O pendientes.
Sacar el trabajo del hilo principal
Dividir el trabajo mantiene la aplicación receptiva, pero no hace que el trabajo sea más rápido.
Para las tareas realmente intensivas en CPU, utiliza otro hilo: un Web Worker en el navegador, o worker_threads en Node.js.
import {Worker} from 'node:worker_threads';
const worker = new Worker(new URL('./fibonacci.worker.mjs', import.meta.url), {workerData: 40});
worker.on('message', result => console.log(`fibonacci(40) = ${result}`));
const tick = setInterval(() => console.log('main thread is still responsive'), 250);
worker.on('exit', () => clearInterval(tick));import {parentPort, workerData} from 'node:worker_threads';
const fibonacci = n => (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
parentPort.postMessage(fibonacci(workerData));main thread is still responsive
main thread is still responsive
fibonacci(40) = 102334155El worker calcula en su propio hilo con su propio event loop, y el resultado llega al hilo principal como una tarea de mensaje.
Los workers no comparten memoria con el hilo principal (salvo mediante SharedArrayBuffer), así que no hay estado compartido que proteger.
Si tienes curiosidad por cómo lo abordan otros lenguajes, compáralo con las goroutines y channels de Go o con los virtual threads de Java, que en su lugar multiplexan muchos hilos ligeros.
Fugas de temporizadores y listeners
Un setInterval activo mantiene una referencia a su callback, y el callback mantiene referencias a todo lo que captura su closure.
Si nunca lo cancelas, nada de esa memoria puede liberarse, y en Node.js el intervalo también mantiene vivo el proceso.
function startPolling(refresh) {
const interval = setInterval(refresh, 1000);
return () => clearInterval(interval); // Call this when you're done
}Cancela siempre cada setInterval, y retira cada listener que registres con addEventListener, cuando desaparezca el componente, la conexión o la página que los necesita.
Para los temporizadores que no deberían mantener en ejecución un proceso de Node.js, llama a unref() como hace ElectronIM.
Detectar un event loop bloqueado
En el navegador, el panel Performance de Chrome DevTools registra todo lo que se ejecuta en el hilo principal. Cualquier tarea que tarde más de 50 ms es una long task (tarea larga): DevTools la marca con un triángulo rojo y sombrea la parte que supera los 50 ms, que es la que retrasa la respuesta a la interacción del usuario. Despliégala para ver qué funciones consumieron el tiempo.
Para detectar frames lentos en producción, la Long Animation Frames API (disponible en los navegadores basados en Chromium desde Chrome 123) informa de cada frame retrasado más de 50 ms, junto con los scripts que lo provocaron:
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
console.log(`Long frame: ${Math.round(entry.duration)} ms, blocking ${Math.round(entry.blockingDuration)} ms`);
}
}).observe({type: 'long-animation-frame', buffered: true});En Node.js, mide el retardo del event loop (event loop delay): cuánto se retrasan los temporizadores respecto al momento en que debían dispararse.
El módulo integrado perf_hooks proporciona un histograma para ello:
const {monitorEventLoopDelay} = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({resolution: 10});
histogram.enable();
const block = ms => {
const end = Date.now() + ms;
while (Date.now() < end) {
// busy wait
}
};
const work = setInterval(() => block(200), 500);
setTimeout(() => {
clearInterval(work);
histogram.disable();
console.log(`p50 delay: ${(histogram.percentile(50) / 1e6).toFixed(1)} ms`);
console.log(`p99 delay: ${(histogram.percentile(99) / 1e6).toFixed(1)} ms`);
console.log(`max delay: ${(histogram.max / 1e6).toFixed(1)} ms`);
}, 3000);p50 delay: 11.0 ms
p99 delay: 205.5 ms
max delay: 210.6 msLa mediana parece correcta, pero el p99 revela los bloqueos de 200 ms.
Exportar estos percentiles a tu sistema de monitorización es una de las mejores comprobaciones de salud para un servicio Node.js.
performance.eventLoopUtilization() lo complementa indicando la fracción de tiempo que el bucle estuvo ocupado, y node --cpu-prof genera un perfil de CPU que puedes abrir en Chrome DevTools para encontrar la función responsable.
Preguntas de entrevista sobre el event loop
Los puzles sobre el event loop son un clásico de las entrevistas de JavaScript. Intenta predecir la salida antes de abrir la respuesta.
Puzle 1: funciones async, Promises y temporizadores
async function first() {
console.log('first start');
await second();
console.log('first end');
}
async function second() {
console.log('second');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
first();
new Promise(resolve => {
console.log('promise executor');
resolve();
}).then(() => console.log('promise then'));
console.log('script end');Ver la respuesta
script start
first start
second
promise executor
script end
first end
promise then
setTimeoutsecond() y el executor de la Promise se ejecutan de forma síncrona.
second() devuelve una promesa ya resuelta, así que await encola first end inmediatamente, durante la llamada a first(), antes de que se ejecute el executor y se encole promise then.
La tarea del temporizador se ejecuta la última.
Puzle 2: temporizadores dentro de microtareas y microtareas dentro de temporizadores
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => console.log('promise inside timeout 1'));
}, 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => {
console.log('promise 1');
setTimeout(() => console.log('timeout inside promise 1'), 0);
});Ver la respuesta
promise 1
timeout 1
promise inside timeout 1
timeout 2
timeout inside promise 1La microtarea encolada en timeout 1 se ejecuta antes que timeout 2, porque después de cada tarea hay un microtask checkpoint.
El temporizador creado dentro de promise 1 se encola detrás de los dos temporizadores existentes.
Node.js produce la misma salida que los navegadores desde la versión 11; las versiones anteriores ejecutaban todos los temporizadores expirados antes de procesar ninguna microtarea.
Puzle 3: var en un bucle
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}Ver la respuesta
3
3
3Los callbacks solo se ejecutan cuando el bucle ha terminado, y los tres comparten la misma i con ámbito de función, que para entonces vale 3.
Declarar la variable con let crea un nuevo binding en cada iteración e imprime 0, 1, 2.
Antes de que existiera let, la solución habitual era capturar el valor en una Immediately Invoked Function Expression (IIFE).
Preguntas frecuentes
¿Por qué JavaScript es de un solo hilo?
JavaScript se diseñó para añadir comportamiento a las páginas web, donde el código manipula un DOM compartido en respuesta a los eventos del usuario. Un único hilo con semántica de ejecución hasta completarse evita los locks, las condiciones de carrera y los estados inconsistentes del DOM. La concurrencia viene del event loop y de las APIs asíncronas del anfitrión, y el paralelismo, de los workers.
¿Se ejecuta setTimeout(fn, 0) inmediatamente?
No. Encola el callback como una tarea que se ejecuta después del código actual, de todas las microtareas pendientes y, posiblemente, de otras tareas y de una actualización del renderizado. Los navegadores limitan los temporizadores anidados a 4 ms, y Node.js utiliza un mínimo de 1 ms.
¿Qué se ejecuta primero, una microtarea o una macrotarea?
Las microtareas. Después de cada tarea, y cada vez que el call stack se vacía, el event loop vacía la cola de microtareas por completo antes de ejecutar la siguiente tarea o de renderizar.
¿Son iguales los event loops del navegador y de Node.js?
Las reglas del call stack, las tareas y las microtareas son las mismas, pero la estructura es distinta.
El navegador elige entre varias colas de tareas e integra el renderizado; Node.js recorre las fases de libuv y añade process.nextTick y setImmediate.
¿Hace async/await que el código se ejecute en paralelo?
No.
async/await hace que el código asíncrono sea más fácil de escribir, pero todo sigue ejecutándose en el mismo hilo.
Para ejecutar en paralelo código intensivo en CPU, utiliza Web Workers o worker_threads.
Conclusión
El event loop de JavaScript es lo que convierte un lenguaje de un solo hilo en una plataforma asíncrona muy capaz. Tu código se ejecuta hasta completarse en un único call stack, y todo lo demás espera en colas hasta que la pila está vacía.
El modelo mental que debes quedarte es sencillo: una tarea, después todas las microtareas, después quizá un renderizado, y vuelta a empezar.
El código síncrono se ejecuta primero, los callbacks de las Promises y las continuaciones de await se ejecutan en cuanto la pila se vacía, y los temporizadores, la I/O y los eventos se ejecutan de tarea en tarea.
En Node.js, las tareas se organizan en fases, process.nextTick se adelanta a las Promises y setImmediate se ejecuta justo después de la I/O.
Con ese modelo, los puzles de orden dejan de ser puzles, y puedes reconocer los problemas reales: tareas largas que bloquean el bucle, microtareas que lo dejan sin avanzar y temporizadores que provocan fugas. Para seguir leyendo, la sección del event loop de la especificación de HTML, la guía del event loop de Node.js y el artículo de Jake Archibald Tasks, microtasks, queues and schedules son los mejores siguientes pasos.
