Await solo ve una promesa: diseñar APIs Async/Await que asuman la responsabilidad de su trabajo
Qué es lo que realmente observa JavaScript async/await, por qué el trabajo independiente, las funciones de callback, las secuencias y Promise.all escapan a su detección, y cómo diseñar Promises que indiquen que una tarea está completada.
Fuentes: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For
Es fácil interpretar await como “esperar a que termine esta función”, pero el entorno de ejecución hace una promesa mucho más limitada: evalúa una expresión, trata el resultado como un valor similar a una Promise y suspende la función async actual hasta que ese único valor se resuelva. Todo lo demás depende de cómo se generó ese valor. Esa diferencia explica por qué el código con palabras clave await colocadas con cuidado aún puede devolver resultados demasiado pronto, ejecutarse más lentamente de lo necesario, ignorar errores o actuar con datos obsoletos. Después de analizar los casos a continuación, podrás observar cualquier await y hacerte la única pregunta importante: ¿esta Promise representa realmente la operación que me interesa?
El problema: código en espera que parece terminado pero no lo está
Los errores en esta área comparten un patrón. Un desarrollador espera a que se ejecute una función y asume que todo lo que esa función inició ya se ha completado. Espera a que se ejecute un método de array y supone que todos los callbacks asíncronos ya se han ejecutado. Espera a que se configure un temporizador, se suscriba a un evento o se escriba en un flujo de datos, y cree que la operación subyacente ya ha terminado. El código funciona correctamente porque await está justo allí.
Pero await no tiene conciencia de “la tarea”, “la solicitud” ni “todo lo que esta función inició”. Si la expresión que se encuentra a su derecha no abarca todo el ciclo de vida del trabajo, nada relacionado con esta palabra clave puede extenderse para cubrirlo. Tener esto en cuenta cambia la forma en que diseñas APIs, bucles, tareas en segundo plano, código basado en eventos y manejo de errores.
La llamada ocurre antes de la espera
Tomemos la línea más común en JavaScript asíncrono.
const user = await loadUser();
El entorno de ejecución no ve primero await y luego pide amablemente a loadUser que se ejecute de forma asíncrona. Evalúa loadUser() exactamente como cualquier otra llamada a función. Esa llamada produce un valor, que puede ser una Promise nativa, un objeto personalizado que implemente el método then, un objeto simple, una cadena, undefined o cualquier otra cosa. Solo después de eso await comienza a trabajar con el resultado.
La consecuencia es que la operación real suele iniciarse durante la llamada, antes de que comience cualquier espera. Si se invocan dos funciones asíncronas y se conservan sus Promises antes de esperar a cualquiera de ellas, ambas operaciones comienzan casi simultáneamente:
const userPromise = loadUser();
const settingsPromise = loadSettings();
const user = await userPromise;
const settings = await settingsPromise;
Aquí loadUser() inicia su trabajo y devuelve una Promise; luego loadSettings() comienza el suyo. Las dos líneas await simplemente recogen los resultados a medida que están disponibles.
Compare eso con la versión que la mayoría de las personas escribe por defecto:
const user = await loadUser();
const settings = await loadSettings();
Ahora loadSettings() ni siquiera se invoca hasta que la primera Promise se resuelve y la función vuelve a ejecutarse. Las dos solicitudes se realizan una tras otra.
Tenga en cuenta que await en sí mismo no toma ninguna decisión sobre el paralelismo; lo que lo hace es la posición de las llamadas a función. Una forma concisa de recordarlo: llamar a una función asíncrona inicia la operación; await decide cuándo continúa la función actual. Una precaución con el primer patrón: si settingsPromise rechaza mientras aún se está esperando a userPromise, ese rechazo queda temporalmente sin manejar, lo cual algunos entornos de ejecución registran como un error. Promise.all evita este problema al observar ambos Promises al mismo tiempo. Para una comparación más detallada de estos enfoques, consulte cómo elegir entre Promise.all, Promise.race y await secuenciales.
Resolución de un Promise, no de todos los efectos secundarios
Considere una función de creación de cuentas que almacena un registro, envía un correo de bienvenida sin esperar ni recibir respuesta, y luego devuelve la cuenta.
async function createAccount(input) {
const account = await accountRepository.save(input);
sendWelcomeEmail(account.email);
return account;
}
Luego, un llamante escribe la frase obvia:
const account = await createAccount(input);
Este llamador se reanuda cuando la Promise devuelta por createAccount se cumple, lo cual ocurre después de que se complete el guardado y la función devuelva un valor. No le proporciona ninguna información sobre el correo electrónico. sendWelcomeEmail creó una operación asíncrona separada cuya Promise nunca se vinculó con la que devuelve createAccount; por lo tanto, desde el punto de vista del llamador, esa tarea no existe.
Este es el núcleo del malentendido. await solo sigue la cadena de Promises a las que se puede acceder a partir del valor que se le proporcionó. No escanea todo lo que inició la función, y ciertamente no espera a que el proceso quede inactivo.
Las consecuencias prácticas son tres:
- Si la Promise del correo electrónico falla, el
try/catchdel llamador nunca la detectará. - Si el proceso se detiene, es posible que el correo electrónico simplemente no se envíe nunca.
Nada de esto implica que todos los efectos secundarios deban completarse dentro de la solicitud. Hay muchas tareas que legítimamente pertenecen al fondo. El verdadero defecto es la falta de claridad en la responsabilidad por ellas. Las tareas importantes deben devolverse y esperarse, marcarse explícitamente como opcionales y gestionarse como tales, o entregarse a una cola o sistema de tareas duradero. Crear una Promise y dejarla sin atención no constituye procesamiento en segundo plano; es simplemente esperar.
Una función asíncrona puede finalizarse mientras sus subprocesos siguen en ejecución
Una suposición relacionada es que la palabra clave async hace que JavaScript supervise cada operación asíncrona iniciada dentro de la función. Pero no es así. Una función async genera exactamente una Promise, y esa Promise solo está vinculada a lo que la función devuelve explícitamente o a lo que espera a lo largo de su propio flujo de control. Cualquier otra cosa puede seguir ejecutándose después de que esa Promise se haya resuelto.
La ilustración clásica utiliza forEach:
async function processUsers(users) {
users.forEach(async (user) => {
await updateUser(user);
});
}
Cada llamada al callback asíncrono devuelve una Promise, pero forEach descarta esos valores devueltos. No los recopila, no espera por ellos ni los muestra; en su lugar devuelve undefined. Por lo tanto, processUsers llega a su corchete de cierre casi al instante, y como una función async que termina así se resuelve con undefined, quien la llama continúa mucho antes de que finalice cualquier actualización:
await processUsers(users);
console.log("All users processed");
La afirmación es simplemente falsa. El llamante esperó a que processUsers terminara de programar las llamadas de retorno, no a que esas llamadas completaran su trabajo.
Para solucionarlo, las Promises hijas deben conectarse a la operación padre. Dependiendo de la situación, esto implica un bucle for...of con await dentro para un secuenciamiento estricto, Promise.all(users.map(updateUser)) para un paralelismo total, o un limitador de concurrencia cuando la colección sea lo suficientemente grande como para sobrecargar una base de datos o una API. La regla general va mucho más allá de forEach: una Promise padre solo espera a que se complete el trabajo de las Promises hijas que forman parte de su cadena. Herramientas como las reglas no-floating-promises y no-misused-promises de typescript-eslint detectan automáticamente muchos de estos errores.
Las APIs de llamadas de retorno son invisibles para await
Una gran parte del ecosistema de JavaScript existe desde antes de Promises y reporta la finalización a través de callbacks. Eso da como resultado código como este:
await setTimeout(() => {
console.log("Timer completed");
}, 1000);
No hace una pausa ni un segundo. setTimeout registra la función de callback e inmediatamente devuelve un identificador del temporizador (un número en los navegadores, un objeto Timeout en Node.js). Ese identificador no es una Promise que indique cuándo se dispara el temporizador. await recibe un valor ordinario, lo trata como si ya estuviera disponible y reanuda la función en la siguiente microtarea, mucho antes de que se ejecute la función de callback.
La misma situación ocurre con los listeners de eventos, las APIs antiguas del sistema de archivos, los controladores de base de datos, los flujos de datos y los SDKs desarrollados manualmente. Si una función devuelve una suscripción, un identificador de solicitud, un valor booleano o un emisor de eventos, esperar ese valor devuelto no convierte la eventual función de callback en la finalización de una Promise.
Para esperar un comportamiento basado en funciones de callback, se necesita una Promise que se resuelva o rechace exactamente cuando la función de callback indique el resultado:
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
await delay(1000);
Ahora el valor esperado realmente representa el evento que nos interesa: la Promise permanece pendiente hasta que el temporizador llama a resolve. Las plataformas modernas suelen incluir ya tales envoltorios; Node.js, por ejemplo, ofrece un setTimeout basado en Promise en node:timers/promises y util.promisify para funciones de estilo callback.
Esto plantea la cuestión de diseño que se esconde detrás de cada await: ¿el valor devuelto representa la finalización, o simplemente el registro, el inicio o un identificador para una tarea que aún está en curso? Si se trata de uno de los últimos casos, esperarlo no significará esperar el resultado que uno imagina.
Thenables, valores simples y el límite de las microtareas
await no exige que se trate de una instancia real de Promise. Si el valor es un objeto thenable, es decir, un objeto que cuenta con un método .then llamable, await adopta el mecanismo de resolución de dicho objeto. Esto permite que las bibliotecas compatibles con Promise y los objetos asíncronos personalizados se integren directamente en funciones async, y significa que un objeto puede comportarse de forma asíncrona sin necesidad de pasar por new Promise.
En el caso de valores que no son thenables, await sigue insertando un paso asíncrono:
async function example() {
console.log("before");
await 42;
console.log("after");
}
console.log("start");
example();
console.log("end");
Esto imprime start, before, end y solo luego after, aunque 42 estuvo disponible de inmediato. La función emite el valor en el punto await, el código síncrono posterior a la llamada continúa, y el resto de example se ejecuta más tarde como una microtarea.
Por lo tanto, esperar un valor simple no es síncrono. Puede reordenar tu código en relación con las instrucciones síncronas circundantes y otras microtareas en cola, y en rutas sensibles al orden o de alto tráfico, un await innecesario añade un límite de programación. La demora suele ser insignificante desde el punto de vista del rendimiento, pero el concepto permanece: el código después de await nunca continúa en la misma pila de llamadas ininterrumpida.
El rechazo forma parte del proceso de resolución
La gente suele decir que await espera a que una Promise se resuelva. Es más preciso afirmar que espera a que la Promise se establezca, y el establecimiento incluye la rechazo. Cuando la Promise esperada se rechaza, await lanza la razón del rechazo dentro de la función async actual, y eso es lo que permite que los bloques habituales try/catch funcionen para los fallos asíncronos:
try {
const order = await loadOrder(orderId);
return order;
} catch (error) {
logger.error(error);
throw error;
}
Esa protección solo se aplica si la Promise que rechaza se espera realmente dentro del bloque try. Una Promise creada y dejada sin manejo la elude por completo:
try {
loadOrder(orderId);
} catch (error) {
console.error("This does not catch the Promise rejection");
}
Aquí la llamada en sí tuvo éxito: devolvió una Promise sin lanzar excepción alguna. La rechazo llega más tarde, a través de esa Promise, después de que el bloque try ya haya finalizado hace rato.
Esto es particularmente peligroso cuando se omite intencionadamente await para hacer algo “no bloqueante”, mientras el desarrollador sigue creyendo que los bloques try/catch circundantes lo protegen. Pero no es así. Cada operación asíncrona necesita un responsable claro en caso de fallo: un llamante que la espere o la devuelva, un manejador .catch explícito, o un sistema duradero que asuma la responsabilidad. Una Promise sin responsable no representa una arquitectura más rápida; es un camino de error desconectado del código que comprende qué salió mal. El artículo sobre qué realmente garantiza async/await explora la misma idea de responsabilidad desde el punto de vista del manejo de errores.
Promise.all coordina resultados, no resultados finales
Promise.all suele resumirse como “esperar varios Promises al mismo tiempo”. Eso está bien como primera aproximación, pero los detalles son importantes. Devuelve un nuevo Promise que se cumple una vez que todos los elementos de entrada se hayan cumplido y se rechaza en cuanto cualquiera de ellos se rechace.
const results = await Promise.all(tasks);
Incluso aquí, await tiene un único objetivo: la Promise agregada que creó Promise.all.
Cuando una tarea falla tempranamente, esa Promise agregada falla junto con ella y su función sigue el camino de error correspondiente. Las tareas restantes no se cancelan; continúan ejecutándose y aún pueden generar efectos secundarios después de que quien llama ya haya considerado el lote como fallido. Imagine un lote que ejecuta varias actualizaciones externas: una falla de inmediato, quien llama informa “lote fallido”, mientras que las demás actualizaciones tienen éxito silenciosamente, dejando al sistema parcialmente modificado.
Promise.all le brinda coordinación de resultados, no transacciones ni cancelaciones. Elija la herramienta que se ajuste a las semánticas que necesita:
- Si el éxito parcial es aceptable, recoja cada resultado (por ejemplo, con
Promise.allSettled) e inspeccione cada uno. - Si se necesita un comportamiento de todo-o-nada, las operaciones subyacentes requieren un mecanismo real de reversión o compensación.
- Si el trabajo restante debe detenerse tras el primer fallo, cada operación debe permitir la cancelación, generalmente a través de un
AbortSignalcompartido.
La Promise combinada no puede deshacer el trabajo que ya ha comenzado. Su resolución solo significa lo que su estructura le permite significar.
Flujos y recursos de larga duración
Los flujos son otra fuente frecuente de confusión. El método write de un flujo puede devolver un valor booleano que indica si el búfer interno puede aceptar más datos. Una llamada a .pipe() devuelve el flujo de destino. Un analizador basado en eventos puede devolver resultados tan pronto como se conecten sus receptores. Esperar cualquiera de esos valores no implica esperar a que el flujo termine, vacíe su contenido, se cierre o escriba cada byte.
La finalización suele indicarse a través de eventos como finish, end, close o error, y para esperarla de manera fiable esos eventos deben convertirse en una Promise. Muchas bibliotecas, y Node.js mismo a través de pipeline en node:stream/promises, ofrecen herramientas que generan una única Promise cuya resolución abarca todo el pipeline, incluidos sus errores.
Este principio se generaliza a cualquier recurso de larga duración:
- Comenzar una carga no significa finalizarla.
- Abrir una conexión no implica completar una consulta.
- Programar un trabajo no equivale a ejecutarlo.
- Colocar un mensaje en cola no significa procesarlo.
El valor que devuelve una API define qué puede observar await. Antes de confiar en un await, averigüe qué límite del ciclo de vida marca realmente su Promise.
La finalización no es sinónimo de relevancia
Imagínese un cuadro de búsqueda donde un usuario escribe “JavaScript” y luego lo cambia rápidamente a “TypeScript”. Se envían dos solicitudes. La de TypeScript responde primero y la pantalla muestra los resultados correctos; luego llega la respuesta más lenta de JavaScript y los sobrescribe.
Toda función en ese flujo utilizó await correctamente. El error existe porque la espera y la relevancia son cuestiones separadas. await reanuda su ejecución cuando una Promise se resuelve; no verifica si alguien sigue necesitando el resultado, si una solicitud más reciente lo ha reemplazado o si el componente ahora muestra un registro diferente. El código que parece secuencial solo ordena los pasos dentro de una única llamada, y múltiples llamadas a la misma función pueden seguir superponiéndose libremente.
Para resolver esto se necesita una noción explícita de qué operación está en curso. Puede cancelar solicitudes obsoletas, comparar cada respuesta con el identificador de la solicitud más reciente, utilizar verificaciones de versión o confiar en una capa de obtención de datos que organice las respuestas según los parámetros que las generaron. await no puede indicar qué resultado pertenece al momento actual. Para una guía centrada en React, consulte cómo solucionar las condiciones de carrera que el rebote no puede resolver.
El hecho de que se haya cumplido no significa que el sistema sea consistente
Una escritura en la base de datos puede tener éxito mientras las réplicas aún están desactualizadas. La publicación de un mensaje generalmente se resuelve una vez que el broker lo acepta, no una vez que un consumidor lo ha procesado. El envío de un correo electrónico suele resolverse cuando el proveedor acepta la solicitud, no cuando alguien lo recibe. Es fácil interpretar en await más confirmaciones de las que la API realmente ofrece.
await publishEvent(event);
Esta línea puede garantizar que el evento llegó al corredor. No implica necesariamente que las proyecciones, notificaciones o índices de búsqueda en las etapas posteriores ya hayan sido actualizados.
await sendEmail(message);
De manera similar, esto podría significar únicamente que el proveedor aceptó el mensaje. No garantiza su entrega, ni que el destinatario lo haya leído, ni siquiera que el proveedor lo haya conservado tras el procesamiento posterior.
Lo que significa “completado” está definido por el contrato de la API que genera la Promise, y await no puede reforzar ese contrato. Los problemas surgen cuando la línea siguiente asume que ya ha ocurrido un cambio de estado más amplio, como un endpoint que publica un evento y consulta inmediatamente un modelo de lectura mantenido por un consumidor, esperando que los nuevos datos ya estén allí. La Promise se cumplió correctamente; simplemente la arquitectura aún está adaptándose. Dependiendo del producto, un diseño adecuado podría devolver un estado pendiente, leer desde el modelo de escritura en su lugar, esperar una confirmación explícita o adoptar la consistencia eventual en la interfaz de usuario.
Design Promises que coinciden con su nombre
La mayor parte de la confusión relacionada con await desaparece una vez que se convierte en hábito hacerse una pregunta: ¿el valor que estoy esperando representa la finalización de toda la operación que me interesa? Al analizar los casos anteriores, la respuesta es claramente no en los siguientes casos:
- una función inicia Promises hijos sin devolverlos ni esperarlos
- un método de array descarta las Promises que devuelven sus callbacks asíncronos
- una API devuelve un identificador y notifica la finalización a través de eventos
- un emisor resuelve antes de que los consumidores hayan completado su tarea
La presencia de await solo demuestra que la función actual se pausó hasta que un valor similar a una Promise se resolvió. No demuestra que el trabajo relacionado haya finalizado, que los efectos secundarios ocurrieran exactamente una vez, que el trabajo anterior haya sido cancelado, que los errores sigan estando vinculados, o que el resultado siga siendo vigente.
Por esta razón, lograr que el código asíncrono funcione correctamente es en gran medida un problema de diseño de API. La Promise que devuelve una función debe resolverse en el momento que corresponda a la responsabilidad que indica su nombre. Una función llamada saveDocument debe dejar claro si su Promise se refiere a la persistencia local, a la sincronización remota o a ambas. Una función llamada scheduleReport puede resolverse legítimamente al aceptarse el trabajo en lugar de al completarse el informe, pero su nombre y tipo de retorno deben dejarlo explícito, por ejemplo resolviéndose con un identificador del trabajo que el llamante pueda monitorear.
Qué puede y qué no puede prometer await
awaitevalúa una expresión y espera a que ese único valor se resuelva; no sabe nada sobre “la tarea” en su conjunto.- El lugar donde se llaman las funciones asíncronas determina si el trabajo se ejecuta en paralelo;
awaitsolo decide cuándo se reanuda. - El trabajo que no se devuelve ni se espera queda desconectado: sus errores, tiempos y finalización son invisibles para quien lo llama.
- Las API de callback, eventos y flujos deben envolverse en Promesas que se resuelvan realmente antes de que
awaittenga sentido. Promise.allni cancela ni revierte operaciones; elijaallSettled, compensación oAbortSignalsegún la semántica que necesite.- La finalización, la relevancia y la consistencia son tres garantías diferentes, y solo la primera proviene de
await. - Trate cada Promesa que devuelva como un contrato, y haga que su resolución signifique exactamente lo que implica el nombre de la función.
Leer más
- Refactoring de cadenas de Promise a Async/Await sin cambiar el comportamiento: Cómo convertir cadenas .then() en código async/await legible, qué es lo que realmente detiene a await, cuándo ejecutar pasos en paralelo y cómo try/catch reemplaza a .catch().
- Concepciones comunes sobre async/await que causan errores en producción: Explica nueve malentendidos sutiles sobre async/await, desde condiciones de carrera hasta rechazos no manejados, que dañan silenciosamente las aplicaciones JavaScript en el mundo real.