Depurar código asíncrono en JavaScript: traza el event loop
Cómo depurar código asíncrono en JavaScript: predice el orden de las colas del event loop, usa DevTools y Node para trazarlo y corrige 4 bugs de orden reales.

El test pasa en tu máquina. En CI falla una de cada cinco veces. Nadie ha tocado ese código en semanas.
Añades un console.log y el fallo desaparece. Lo quitas y vuelve. Así empiezan los setTimeout(..., 0) repartidos hasta que "funciona".
Eso no es depurar, es apostar. El problema casi nunca es el código que miras: es el orden en que el event loop lo ejecuta.
En corto: para depurar código asíncrono en JavaScript, escribe el orden que esperas antes de ejecutar, ejecuta y compara: cada discrepancia te dice qué cola entendiste mal. Las herramientas clave: async stack traces, panel Performance, --unhandled-rejections y monitorEventLoopDelay(). Cuatro bugs explican la mayoría de los fallos de orden: await dentro de forEach, promesas flotantes, setTimeout(0) como sincronización y microtasks que se reencolan sin fin.
Si necesitas la base del event loop, empieza por Event Loop, Microtasks y Concurrencia Interna. Aquí toca trazar colas y cazar bugs.
¿Qué significa depurar código asíncrono en JavaScript?
Depurar código asíncrono en JavaScript es reconstruir en qué cola (síncrona, microtasks, nextTick o tareas/macrotasks) se encoló cada callback y en qué momento la vació el event loop, para encontrar el punto donde el orden real se separa del orden que el código asumía.
El stack trace clásico no basta: cuando el callback corre, la función que lo encoló ya terminó.
Cómo predecir el orden de ejecución del event loop
Para depurar código asíncrono en JavaScript hacen falta tres pasos: snippet mínimo, predicción por escrito y ejecución. Sin la predicción, cualquier resultado "tiene sentido" a posteriori.
Antes de leer la salida, ordena estas líneas:
// orden.mjs
console.log(39;1 sync39;);
setTimeout(() => console.log(39;6 setTimeout39;), 0);
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => { console.log(39;7 MessageChannel39;); port1.close(); };
port2.postMessage(null);
Promise.resolve().then(() => console.log(39;3 then39;));
queueMicrotask(() => console.log(39;4 queueMicrotask39;));
(async () => {
console.log(39;2 async fn (parte síncrona)39;);
await null;
console.log(39;5 tras await39;);
})();
Salida real con Node 24.16:
1 sync
2 async fn (parte síncrona)
3 then
4 queueMicrotask
5 tras await
6 setTimeout
7 MessageChannel
Las cinco primeras líneas son deterministas: primero todo lo síncrono (incluido el cuerpo de una función async hasta su primer await), después la cola de microtasks entera en orden FIFO.
Las dos últimas, no. En 40 ejecuciones con Node 24.16, 26 veces salió setTimeout primero y 14 MessageChannel primero. Son dos fuentes de tareas distintas y nadie te garantiza cuál va antes.
En el navegador, la spec HTML dice lo mismo con otras palabras: tras cada tarea se hace un microtask checkpoint (se vacían todas las microtasks), y requestAnimationFrame corre en el paso de "update the rendering", que solo ocurre cuando el navegador decide pintar. Este bloque sigue el modelo de procesamiento de la spec, no una ejecución:
// En el navegador
setTimeout(() => console.log(39;timeout39;), 0);
requestAnimationFrame(() => console.log(39;rAF39;));
Promise.resolve().then(() => console.log(39;microtask39;));
// 'microtask' siempre primero.
// 'timeout' y 'rAF': depende de cuándo toque el siguiente frame. No hay orden garantizado.
La relación entre microtasks y pintado la tienes desarrollada en cómo afectan los microtasks a la renderización del browser.
Node añade dos colas más: process.nextTick y setImmediate
Node tiene fases propias (timers, poll, check…), explicadas en el post sobre el event loop de Node y agentes de IA. Para depurar, lo que importa es el orden práctico:
// node-colas.cjs
const fs = require(39;node:fs39;);
console.log(39;sync39;);
setTimeout(() => console.log(39;setTimeout 039;), 0);
setImmediate(() => console.log(39;setImmediate39;));
Promise.resolve().then(() => console.log(39;promise.then39;));
process.nextTick(() => console.log(39;nextTick39;));
fs.readFile(__filename, () => {
console.log(39;--- dentro de un callback de I/O ---39;);
setTimeout(() => console.log(39;I/O > setTimeout 039;), 0);
setImmediate(() => console.log(39;I/O > setImmediate39;));
process.nextTick(() => console.log(39;I/O > nextTick39;));
});
sync
nextTick
promise.then
setTimeout 0
setImmediate
--- dentro de un callback de I/O ---
I/O > nextTick
I/O > setImmediate
I/O > setTimeout 0
Primera trampa: setTimeout 0 antes que setImmediate en el módulo principal no está garantizado. Con node -e y 40 ejecuciones salió setImmediate primero 39 veces; ejecutando el fichero, en otra prueba de 30, ganó setTimeout 26. La guía oficial de Node lo llama no determinista; dentro de un callback de I/O, en cambio, setImmediate gana siempre.
Segunda trampa: cambia la extensión a .mjs.
// colas.mjs
Promise.resolve().then(() => console.log(39;promise.then39;));
process.nextTick(() => console.log(39;nextTick39;));
promise.then
nextTick
En CommonJS sale al revés. La documentación de process lo explica: un módulo ESM ya se evalúa dentro de la cola de microtasks, así que esa cola se vacía antes de llegar a nextTick. Migrar a ESM cambia el orden sin tocar una línea.
¿Qué cola usa cada API asíncrona en JavaScript y Node?
Cada API asíncrona encola su callback en una cola distinta, y el event loop las vacía con prioridades diferentes: síncrono, nextTick (Node), microtasks, tareas y render. Saber en cuál cae cada callback es la mitad del diagnóstico.
| Cola | APIs que encolan | Cuándo se vacía | Riesgo típico al depurar |
|---|---|---|---|
| Síncrona (call stack) | Código de nivel superior, cuerpo de async hasta el primer await |
Inmediatamente | Creer que todo async es diferido |
nextTick (solo Node) |
process.nextTick |
Tras cada operación, antes de las microtasks en CJS | Orden invertido al pasar a ESM; starvation si se reencola |
| Microtasks | Promise.then/catch/finally, await, queueMicrotask, MutationObserver |
Entera, tras cada tarea | Bucles que nunca ceden el hilo y congelan la UI |
| Tareas (macrotasks) | setTimeout, setInterval, MessageChannel, eventos, I/O |
Navegador: una por vuelta del loop. Node: todas las de la fase, con microtasks entre cada callback | Asumir orden entre fuentes distintas |
setImmediate (solo Node) |
setImmediate |
Fase check, tras poll | Orden frente a setTimeout(0) no determinista en el módulo principal |
| Render (navegador) | requestAnimationFrame |
Antes de pintar, solo si hay frame | Usar setTimeout(0) como "espera al DOM" |
Herramientas para trazar colas en DevTools y Node
Async stack traces. Chrome DevTools cose el stack de donde se encoló el callback con el de donde se ejecuta (artículo de Chrome). En Node, V8 enriquece Error.stack con frames async, pero solo a través de await (y de Promise.all/Promise.any cuando se esperan con await), como detalla el blog de V8 sobre async rápido. La consecuencia práctica:
// trace.mjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function loadUser() { await wait(5); return fetchProfile(); }
async function fetchProfile() { await wait(5); console.trace(39;¿quién me llamó?39;); }
async function main() { await loadUser(); }
main();
Trace: ¿quién me llamó?
at fetchProfile (trace.mjs:3:56)
at async main (trace.mjs:4:25)
loadUser ha desaparecido del rastro. Cambia return fetchProfile() por return await fetchProfile() y vuelve:
Trace: ¿quién me llamó?
at fetchProfile (trace.mjs:3:56)
at async loadUser (trace.mjs:2:51)
at async main (trace.mjs:4:25)
Si un error "viene de la nada", busca return de promesas sin await.
Breakpoints de excepciones. En Sources, sección Breakpoints, activa "Pause on caught exceptions" (docs). Para promesas que alguien rechaza y otro silencia con un .catch(() => {}), es la forma más directa de verlas.
Panel Performance. Graba, y las tareas largas aparecen con un triángulo rojo y la parte que pasa de 50 ms sombreada en rojo (referencia del panel). Si una tarea larga está llena de microtasks, ese es tu congelamiento.
Node. node --inspect-brk app.js abre el inspector en 127.0.0.1:9229 y para en la primera línea; conecta desde chrome://inspect. --trace-warnings da el stack de cada warning y --trace-uncaught el del punto del lanzamiento (CLI de Node).
Medir el lag del loop. monitorEventLoopDelay() devuelve un histograma en nanosegundos:
// lag.mjs
import { monitorEventLoopDelay } from 39;node:perf_hooks39;;
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setTimeout(() => {
const end = Date.now() + 200;
while (Date.now() < end) {} // 200 ms de CPU síncrona
}, 50);
setTimeout(() => {
h.disable();
const ms = (ns) => (ns / 1e6).toFixed(1);
console.log(`p50=${ms(h.percentile(50))}ms p99=${ms(h.percentile(99))}ms max=${ms(h.max)}ms`);
}, 500);
p50=15.7ms p99=215.2ms max=215.2ms
Son valores de una ejecución y cambian entre máquinas, pero el patrón no: el p50 parece sano y el p99 delata el bloqueo de 200 ms. Ojo: cada muestra incluye los 10 ms de resolution, así que ese p50 de ~15 ms son unos 5 ms de lag real. Mira siempre la cola, no la media.
Para seguir una petición a través de varios await en los logs, usa AsyncLocalStorage de node:async_hooks, que es estable. createHook es experimental y la propia documentación de async_hooks recomienda migrar fuera de esa API.
4 bugs de orden reales y cómo corregirlos
Estos cuatro patrones explican la mayoría de los fallos al depurar código asíncrono en JavaScript.
1. await dentro de forEach
// foreach.mjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const ids = [3, 1, 2];
async function saveAll() {
ids.forEach(async (id) => {
await wait(id * 10);
console.log(39;guardado39;, id);
});
console.log(39;saveAll terminó39;);
}
await saveAll();
saveAll terminó
guardado 1
guardado 2
guardado 3
forEach ignora la promesa que devuelve cada callback. Es la pregunta «Using async/await with a forEach loop» de Stack Overflow: más de 3.300 votos y casi 2,5 millones de visitas a 11 de octubre de 2026. La respuesta aceptada, de Bergi, lo resume: el código funciona, pero no hace lo que crees. Para secuencial, for...of con await; para paralelo, await Promise.all(ids.map(async (id) => …)). Así, los tres guardado salen antes del mensaje de fin.
2. La promesa flotante
// flotante.mjs
async function audit(event) {
throw new Error(`audit falló para ${event}`);
}
function handler() {
audit(39;login39;); // sin await ni catch
console.log(39;respuesta enviada39;);
}
handler();
setTimeout(() => console.log(39;timer pendiente39;), 10);
Con Node 24 el proceso imprime respuesta enviada y muere con código de salida 1: timer pendiente nunca aparece. Desde Node 15 el modo por defecto de --unhandled-rejections es throw (CLI). Con --unhandled-rejections=warn el proceso termina con 0 y el error queda enterrado en un UnhandledPromiseRejectionWarning. El fix es await o un .catch() explícito, y en TypeScript la regla @typescript-eslint/no-floating-promises los caza antes del commit.
3. setTimeout(0) "para esperar al DOM"
Un setTimeout(0) no espera al render: encola una tarea que puede correr antes o después del siguiente frame. Para medir layout tras un cambio usa requestAnimationFrame; en Angular, afterNextRender, que funciona igual con o sin Zone.js (de lo segundo hablo en Angular sin Zone.js). Si estás poniéndote al día con ese Angular, el curso de Angular Moderno es el punto de partida.
4. Microtasks que se reencolan
// starvation.mjs
let n = 0;
setTimeout(() => console.log(39;timer, tras39;, n, 39;microtasks39;), 0);
function spin() {
if (++n < 1_000_000) queueMicrotask(spin);
}
spin();
timer, tras 1000000 microtasks
El timer de 0 ms espera a que termine el millón entero. En el navegador eso es una UI congelada, porque tampoco se pinta. Trocea con setTimeout o scheduler.yield() donde esté disponible para devolver el control al loop.
Límites al depurar código asíncrono en JavaScript
La observación cambia el resultado. Un console.log o un breakpoint alteran los tiempos, y en órdenes no deterministas el bug puede desaparecer mientras lo miras. Ahí sirven más los logs con timestamp y AsyncLocalStorage que el depurador paso a paso.
Las async stack traces tienen agujeros. Se pierden con return sin await, con callbacks de librerías nativas y entre procesos o workers. Un postMessage a un Worker no deja rastro en el stack del hilo principal.
El lag te dice que algo bloquea, no qué. monitorEventLoopDelay detecta el problema; para el culpable necesitas un perfil de CPU (--cpu-prof o el panel Performance).
Lo que vale en Node 24 puede no valer en otro runtime. Los ejemplos se ejecutaron con Node 24.16. Bun y Deno implementan sus propios event loops, y el orden de nextTick o setImmediate no está en ninguna spec común.
Cómo atacar tu próximo bug asíncrono intermitente
Cuando un test falle "a veces", depurar código asíncrono en JavaScript no consiste en meter un setTimeout. Aísla las tres o cuatro líneas que encolan trabajo, escribe el orden que esperas en un comentario y ejecútalo diez veces. Si el orden cambia entre ejecuciones, ya sabes que dependes de dos colas sin garantía.
Si el bug está en los tests y no en producción, en el curso de Testing en Angular con Jest y Testing Library verás cómo testear código asíncrono sin depender del reloj real. Y en el canal de YouTube de Dominicode tienes más contenido de JavaScript y Angular.
Preguntas frecuentes
¿Por qué setTimeout(fn, 0) no se ejecuta inmediatamente?
Porque encola una tarea, y antes de que llegue su turno el event loop termina el código síncrono y vacía todas las microtasks. Además, los navegadores aplican un mínimo de 4 ms a partir del quinto timer anidado. "0" nunca significa "ya".
¿Qué se ejecuta antes, process.nextTick o Promise.then?
En CommonJS, nextTick. En un módulo ESM, Promise.then, porque el módulo ya se evalúa dentro de la cola de microtasks. Lo confirma la documentación de process.
¿Cómo veo el stack completo de un error dentro de un callback asíncrono?
En DevTools, las async stack traces ya lo muestran. En Node, encadena con await (también en los return) para que V8 conserve los frames async, y añade --trace-uncaught si se lanza algo que no es un Error.
¿Cómo detecto que el event loop está bloqueado en producción?
Activa perf_hooks.monitorEventLoopDelay() y exporta el p99 y el máximo a tu sistema de métricas. Un p50 bajo con un p99 alto indica bloqueos puntuales; perfila la CPU para encontrar al culpable.
¿Sirve este método para código generado por IA?
Sí. Los asistentes generan forEach(async …) y promesas sin await con naturalidad. Revisar el orden de las colas es parte de validar ese código, como hacemos en Construye con IA.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.