Ejecuta esto en Node o en la consola del navegador:
| |
El resultado es 1 4 3 2. Un setTimeout con un delay de 0ms igual se ejecuta después del callback de la promise, y ambos esperan a que termine cada línea de código síncrono que tienen debajo. async/await está construido sobre este orden, no es un mecanismo nuevo. La mayoría del comportamiento sorprendente en JavaScript async viene de escribir await como si pausara todo el programa, cuando en realidad solo pausa la función en la que está.
¿Por qué setTimeout se ejecuta después de una promise sin delay?
JavaScript ejecuta tu código en un solo thread con un único call stack. Las funciones se apilan cuando se llaman y se desapilan cuando retornan, y no se ejecuta nada más mientras el stack tenga algo dentro. Los timers, las respuestas de red y las resoluciones de promises los maneja el runtime que rodea al stack (el navegador, o libuv en Node), que encola un callback para más tarde.
En qué cola termina el callback depende del tipo de trabajo:
- La task queue (también llamada macrotask queue) contiene los callbacks de
setTimeoutysetInterval, las I/O completions y los eventos de UI. El event loop toma exactamente una task de esta cola por iteración. - La microtask queue contiene los callbacks de las promises (
.then,.catch,.finally) y todo lo que se programe conqueueMicrotask. El event loop vacía esta cola por completo, incluyendo las microtasks que se agregan mientras la está vaciando, antes de continuar.
Una iteración del loop se ve así:
| |
Entonces, en el fragmento anterior, console.log("4") termina la ejecución síncrona, la microtask queue se vacía e imprime 3, y solo después el loop toma el callback del timer que imprime 2. Si ya trabajaste con una cola de mensajes, el modelo te resultará familiar: el trabajo espera en una fila y un único consumidor procesa un elemento a la vez. La diferencia es que JavaScript tiene dos filas, y la fila de microtasks siempre se atiende primero.
Hay un efecto colateral que conviene conocer: como la microtask queue debe quedar vacía antes de que se ejecute cualquier task, una cadena de promises que sigue programando más callbacks puede dejar a los timers esperando indefinidamente y, en un navegador, bloquear los repaints.
¿Dónde pausa exactamente la ejecución await?
Una función async siempre devuelve una promise, incluso sin return y sin await en su cuerpo. await pausa esa única función: todo lo que viene después se convierte en una continuación que se ejecuta como microtask cuando el valor esperado se resuelve. Es azúcar sintáctico sobre .then(), no un mecanismo distinto.
| |
Esto imprime start, a, end, b. example() se ejecuta de forma síncrona hasta await null, y ahí devuelve el control a quien la llamó. console.log("end") se ejecuta antes de que example se reanude, porque la reanudación se encola como microtask y las microtasks esperan a que termine el código síncrono actual.
Ese único hecho, que await le cede el control a la microtask queue y nada más, explica todos los errores que vienen a continuación. También explica por qué async/await desplazó a las cadenas anidadas de .then(): una cadena que maneja errores en tres niveles distintos es exactamente el flujo de control enredado que describe el spaghetti code, mientras que declaraciones await secuenciales le devuelven al código ese orden de arriba a abajo.
¿Qué pasa si te olvidas de la palabra clave await?
Si te olvidas de un await, obtienes el objeto promise en lugar de su resultado. No hay ningún error, solo datos incorrectos tres líneas más abajo:
| |
getUser(4821) devuelve de inmediato una Promise pendiente, y una Promise no tiene una propiedad name. La solución es una sola palabra: const user = await getUser(id). Lo que hace costoso este bug es el silencio. Nada arroja un error en la línea defectuosa; encuentras un undefined más adelante en el flujo, o un [object Promise] dentro de un template string.
¿Deberías usar await en un loop o Promise.all?
Un await dentro de un loop for termina cada iteración antes de empezar la siguiente, incluso cuando las iteraciones no dependen entre sí:
| |
Si cada llamada a getUser tarda 200ms y ids tiene tres elementos, esto tarda unos 600ms en total. La segunda solicitud no arranca hasta que vuelve la respuesta de la primera. Cuando las solicitudes son independientes entre sí, lánzalas todas juntas y espera el lote:
| |
Ahora las tres solicitudes se disparan juntas y la función retorna en unos 200ms, el tiempo de la más lenta. Promise.all rechaza en cuanto una sola promise rechaza. Si necesitas todos los resultados sin importar fallos individuales, Promise.allSettled te da un estado para cada una en lugar de cortar todo de golpe.
Un await secuencial es la decisión correcta en dos casos. Uno: cada iteración necesita el resultado anterior, como paginar una API con un cursor que viene de la respuesta previa. Dos: estás limitando deliberadamente la velocidad contra un endpoint con rate limit que rechazaría una ráfaga de llamadas concurrentes. Con un array ids grande, Promise.all puede terminar topándose con ese segundo problema, así que limita la concurrencia con un pool pequeño o con una biblioteca como p-limit.
¿Por qué async forEach no espera?
forEach llama a su callback por cada elemento y descarta lo que el callback devuelva, promise incluida:
| |
"done" se imprime antes que cualquier nombre de usuario. forEach arranca los tres callbacks async y retorna sin esperar a ninguno de ellos, y no puedes hacerle await porque devuelve undefined, no una promise. Para trabajo secuencial, usa for...of con await adentro. Para trabajo concurrente, aplica la misma solución que en el loop anterior:
| |
Esto funciona porque map devuelve un array de promises, que es exactamente lo que Promise.all recibe.
¿Por qué un unhandled promise rejection hace crashear Node?
Una función async que lanza un error produce una promise rechazada, no una excepción en el stack frame de quien la llama:
| |
En una pestaña del navegador, esto registra Uncaught (in promise) en la consola y la página sigue funcionando. Node es más estricto: desde Node 15, un unhandled rejection se trata por defecto como una excepción no capturada, y el proceso termina. Maneja los rejections donde ocurren, con un try/catch alrededor del await o con un .catch() en la llamada. Después, como red de último recurso, agrega un handler global:
| |
Registrar este listener reemplaza el crash por defecto de Node, así que el proceso sigue corriendo incluso después de un rejection sin manejar. Úsalo para loguear el error y después cerrar limpiamente (llamando a process.exit(1) después de volcar los logs), no para tapar handlers que faltan.
¿Por qué try/catch no atrapa errores en los callbacks?
Un try/catch solo ve las excepciones que se lanzan mientras su bloque está en ejecución. El código programado para más tarde, en otra task o microtask, queda fuera de su alcance aunque esté escrito en la línea siguiente:
| |
setTimeout programa el callback y retorna, así que el bloque try termina de inmediato. Cuando el callback lanza el error 100ms después, se ejecuta en un call stack nuevo, sin ningún catch por encima.
await es distinto. La función async queda suspendida, no termina: su estado local y su bloque try se conservan, y cuando la promise esperada rechaza, la función se reanuda lanzando el rejection en la línea del await, dentro del try:
| |
Fíjate en el return await. Con un simple return getUser(id), la función devuelve la promise directamente sin esperarla, y un rejection se salta este catch por completo.
¿Cómo detectar estos bugs antes de que lleguen a producción?
La mayoría de estos errores se pueden detectar de forma estática, así que deja que el linter los encuentre en lugar del event loop. En ESLint, activa no-await-in-loop para marcar los awaits secuenciales (y desactívalo en línea donde la secuencia sea deliberada) y no-async-promise-executor. Si usas TypeScript, agrega @typescript-eslint/no-floating-promises y @typescript-eslint/no-misused-promises: la primera atrapa promises que ni se esperan ni se manejan, la segunda atrapa callbacks async que se pasan a lugares como forEach e ignoran la promise que devuelven. Cuando la salida sigue apareciendo en un orden que no esperabas, anota en qué cola termina cada callback antes de tocar el código.