Qué realmente garantiza async/await y qué deja en manos del usuario
Entienda qué es lo que realmente suspende a async/await, cómo evitar solicitudes serializadas, y por qué los errores, las cancelaciones, el ordenamiento y las reintentos requieren diseños que vayan más allá de async/await.
Una función llena de expresiones await parece un script que se ejecuta línea por línea, y esa impresión es precisamente donde comienzan muchos errores asíncronos. Paneles lentos, errores ocultos, resultados de búsqueda desactualizados y pagos cobrados dos veces suelen surgir al esperar que async/await ofrezca garantías que nunca ha proporcionado. Una vez que conozcas el contrato preciso y relativamente sencillo detrás de la sintaxis, podrás ver de inmediato qué comportamientos provienen del lenguaje y cuáles aún debes diseñar.
Aquí hay una función que parece claramente secuencial:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
La lectura natural es: obtener al usuario, esperar, obtener los proyectos, esperar, obtener las notificaciones y luego devolver el resultado. Esa interpretación no está del todo equivocada, pero oculta la parte importante cuando el código necesita ser rápido, concurrente, cancelable o resistente a fallos.
await no indica cómo se realiza el trabajo subyacente. Solo marca el lugar donde la función async que lo contiene debe suspenderse hasta que una promesa se resuelva. Mientras esa función está suspendida, el entorno de ejecución sigue procesando otras tareas. Muchas sorpresas surgen al atribuir al async/await comportamientos que en realidad pertenecen a las promesas, al entorno anfitrión, a una API como fetch, a una estrategia de concurrencia o a la propia aplicación.
await suspende una función, no todo el entorno de ejecución
Considere este pequeño programa, que registra actividades alrededor de una llamada a la red:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
Al llamar a loadUser() se inicia de inmediato la ejecución de su cuerpo, por lo que "A" se registra primero después de "1". Cuando la ejecución llega al await, la función no bloquea el hilo mientras la solicitud está en tránsito; se suspende a sí misma y devuelve el control a quien la llamó, y por eso "2" aparece antes que "B". Una vez que la promesa de fetch se resuelve, el resto de loadUser() se coloca en cola para continuar, y solo entonces se registra "B". El orden resultante es 1, A, 2, B.
Por lo tanto, la traducción correcta de await no es “detener JavaScript aquí”; está más cerca de “todo lo que viene después de esta línea depende de este valor, así que continuar con esta función más tarde”.
Esto ocurre incluso cuando el valor esperado ya está disponible. Esperar una promesa cumplida o un valor que no sea una promesa sigue posponiendo el resto de la función a una microtarea posterior en lugar de continuar de inmediato, como lo documenta MDN. Por eso, incluir expresiones await adicionales aparentemente inofensivas puede cambiar la programación: cada una añade otro límite de microtarea.
La consecuencia práctica es que no se puede comprender un programa tratando cada await como una llamada bloqueante en una función síncrona. Es necesario seguir qué se ha iniciado ya, qué funciones están actualmente suspendidas y qué más se puede ejecutar antes de que una función determinada continúe.
async no traslada el trabajo al hilo principal
La palabra clave async genera otro concepto erróneo. Observe este bucle limitado por la CPU:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Nada de esto es concurrente. Agregar async no coloca expensiveCalculation() en otro hilo, no paraleliza el bucle y no evita que el trabajo síncrono intensivo bloquee cualquier otra operación que quiera ejecutarse en el mismo hilo.
Lo que cambia con async es el contrato de retorno de la función. Llamarla siempre genera una promesa, y devolver un valor ordinario cumple esa promesa con dicho valor. La especificación ECMAScript describe la evaluación de funciones async en términos de una capacidad de promesa que se convierte en el resultado de la función.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Al registrar result se muestra una Promise pendiente o cumplida, no 42. Solo se obtiene el número al esperar la promesa o al llamar a .then() sobre ella.
No obstante, devolver una promesa no hace que el cuerpo de la función sea asíncrono. Todo lo que está antes del primer await se ejecuta de forma síncrona en el momento de la llamada. Si colocas un cálculo costoso dentro de una función asíncrona y esperas que la interfaz de usuario siga siendo receptiva, quedarás decepcionado: la función eventualmente devolverá una promesa, pero el trabajo intensivo seguirá ocupando primero el hilo de ejecución. Para tareas realmente intensivas en términos de CPU, las herramientas disponibles son los Web Workers en el navegador, worker_threads en Node.js, o dividir el trabajo en partes.
En resumen, async/await es una forma de escribir cadenas de promesas que se leen de arriba hacia abajo. Programa las continuaciones; nunca hace que el código bloqueante se ejecute de forma concurrente.
Dónde colocar await puede serializar trabajos independientes
Volviendo al cargador del panel de control:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Supongamos que fetchProjects() no necesita user, y que fetchNotifications() tampoco necesita el resultado anterior. La función sigue realizando tres solicitudes independientes una tras otra, ya que la segunda llamada no se realiza hasta que se cumple la primera promesa, y la tercera espera a la segunda. La latencia total es la suma de las tres:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
Esta solución se describe generalmente como “usar Promise.all() para ejecutarlas en paralelo”. Una descripción más precisa es que todas las operaciones independientes deben iniciarse antes de que la función espere su resultado. Al llamar primero a las tres funciones se crean tres promesas en ejecución, y solo entonces el código espera el resultado combinado:
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
Las solicitudes ahora se superponen, y la latencia total es aproximadamente la del proceso más lento:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() recibe una colección de promesas y devuelve una promesa que se cumple cuando todas las promesas de entrada se hayan cumplido. El array de resultados mantiene el orden de las entradas, independientemente de cuál operación finalice primero, por lo que el desestructurado en user, projects y notifications permanece correcto.
Tenga en cuenta que lo que diferenciaba el código secuencial del concurrente nunca fue la presencia de await; fueron las dependencias. Cuando un paso realmente necesita el resultado de otro, esperar en secuencia es la opción adecuada:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
Cuando no existe tal dependencia, esperar a que termine cada operación antes de iniciar la siguiente añade latencia sin mejorar la corrección del código. Por lo tanto, la pregunta útil en una revisión de código no es “¿debería usarse Promise.all()?”, sino “¿qué operaciones dependen de resultados anteriores y cuáles ya podrían estar en ejecución?”
Promise.all() coordina los resultados; no cancela tareas
Al descubrir Promise.all(), muchos desarrolladores forman una nueva suposición sobre lo que ocurre en caso de error. Tomen esta versión:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
Si fetchProjects() rechaza tempranamente, la promesa combinada se rechaza de inmediato con esa razón en lugar de esperar al resto. Es tentador pensar que las otras dos solicitudes se han detenido ahora, pero no es así. El rechazo de la promesa agregada no cancela nada que ya haya comenzado, algo que MDN indica explícitamente. Las demás operaciones siguen ejecutándose, y sus resultados finales simplemente se ignoran.
Con lecturas esto suele desperdiciar recursos. Con escrituras puede ser grave:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
Si writeAuditLog() rechaza, updateProfile() podría ya haberse realizado y sendWebhook() podría ya estar en tránsito. Capturar el rechazo no anula ninguna de estas acciones.
Ese es un problema de diseño de sistema, no un problema de sintaxis. Si estos pasos deben tener éxito o fracasar juntos, se necesita un mecanismo real de atomicidad: una transacción de base de datos para los cambios en una base de datos, o para sistemas remotos alguna combinación de acciones compensatorias, operaciones idempotentes y estado persistido del flujo de trabajo. Promise.all() promete informarte cuando un grupo de promesas se ha resuelto. No promete una reversión, cancelación ni que los efectos secundarios ocurran como una sola unidad. Esas garantías deben provenir de otro lugar.
Una herramienta relacionada es Promise.allSettled(), que espera a que todos los elementos de entrada estén listos y reporta cada resultado por separado. Es útil cuando necesitas saber exactamente qué pasos tuvieron éxito, pero tampoco ofrece posibilidad de reversión.
try/catch solo detecta las rechazos que pasan por él
Una de las razones por las que async/await se popularizó es porque los errores de las promesas pueden manejarse con el conocido try/catch:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
Cuando la promesa esperada se rechaza, la expresión await lanza la razón del rechazo dentro de la función, y el catch circundante la recibe al igual que una excepción síncrona.
Eso no significa que try supervise cada operación asíncrona iniciada dentro de sus llaves. Considere la creación de cuentas:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() se espera, por lo que su fallo termina en el bloque catch. sendWelcomeEmail() no se espera. Si devuelve una promesa que más tarde rechaza, ese rechazo nunca llega a este flujo de control, ya que nada espera ni devuelve esa promesa. createAccount() podría haber devuelto ya { success: true } para cuando falla el envío del correo electrónico, y el fallo se manifiesta por separado, generalmente como un rechazo no manejado. En Node.js, los rechazos no manejados terminan el proceso por defecto en las versiones actuales, por lo que esto no es un problema meramente estético.
Omitir await no es necesariamente un error. A veces el correo electrónico se deja intencionadamente fuera de la ruta de la solicitud. Sin embargo, en un diseño para producción, ese tipo de tarea separada suele ir a una cola duradera en lugar de ejecutarse como una promesa sin supervisión. El verdadero error es pensar que el bloque try crea un límite de errores alrededor del trabajo asíncrono futuro solo porque la llamada se encuentra visualmente dentro de él.
La misma trampa aparece en el lugar donde se realiza la llamada. Este llamante parece estar protegido, pero el fragmento es simplemente JavaScript sin await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() devuelve una promesa de inmediato. Cualquier fallo asíncrono rechaza esa promesa más tarde, después de que el bloque try ya haya finalizado, por lo que catch nunca se ejecuta. El llamante debe participar en la cadena de promesas:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
Otra opción es adjuntar un manejador de rechazo con .catch(). La regla subyacente es sencilla una vez que dejas de ver las funciones asíncronas como funciones ordinarias que casualmente contienen await: sus llamantes reciben promesas, y los errores se transmiten a través de ese contrato de promesa.
La cancelación es un protocolo separado
Imagina un campo de búsqueda donde el usuario escribe un carácter a la vez:
r
re
rea
reac
react
Una implementación directa envía una solicitud por cada tecla pulsada, incluso cuando las solicitudes anteriores aún están pendientes:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
Nada en await indica que la solicitud de "r" debería detenerse porque ahora la solicitud de "react" es la importante. La solicitud anterior se ejecuta hasta su finalización aunque la interfaz ya no la necesite.
Para fetch, se expresa la cancelación mediante AbortController y su AbortSignal. Esta versión cancela la solicitud anterior antes de iniciar una nueva:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
El controlador expone una señal a la que escuchan las APIs compatibles. Al cancelarla, se indica a fetch que deje de funcionar, lo que incluye tanto la solicitud en sí como la lectura del cuerpo de la respuesta. Un detalle a tener en cuenta: la llamada cancelada rechaza con un AbortError; por lo tanto, quien llame a search() debe reconocer ese error e ignorarlo en lugar de mostrarlo al usuario.
La expresión “APIs compatibles” es importante. Las promesas no cuentan con una operación de cancelación universal, y await no tiene forma de detener lo que está esperando. La cancelación solo funciona si la operación que se llama la soporta y transmite la señal a aquello que realiza el trabajo real.
Tus propias funciones pueden adoptar el mismo contrato al aceptar una señal y verificarla entre pasos:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() lanza la razón de cancelación si se ha solicitado dicha acción, por lo que el bucle se detiene antes de procesar el siguiente bloque. La cancelación ahora forma parte explícita de la interfaz de la función, en lugar de ser algo que los llamantes esperan que await haga por ellos. Para mayor precisión, también puedes pasar la señal a processChunk() para que un bloque largo pueda detenerse a mitad de camino.
Los tiempos de espera siguen la misma lógica. Utilizar Promise.race() para competir una operación contra un temporizador permite al llamante dejar de esperar, pero la operación subyacente continúa a menos que también se le indique detenerse. Detener la observación y detener el trabajo son dos cosas diferentes.
El orden local no es el orden global
El uso secuencial de await sí garantiza el orden dentro de una misma función:
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() ni siquiera se llama hasta que saveOrder() ha finalizado. Sin embargo, esa garantía local dice muy poco sobre el resto del sistema. Imagine dos solicitudes que actualizan el mismo perfil. Una envía:
await updateProfile({
name: "Umar",
});
Unos pocos milisegundos después, otra envía:
await updateProfile({
name: "Umar Dev",
});
Cada llamante espera correctamente su propia actualización. Nada de esto determina cuál escritura llega primero a la base de datos, cómo se entrelazan los intentos de reintentar desde cada lado, si las dos solicitudes son manejadas por servidores diferentes, o si los datos obsoletos terminan sobrescribiendo a los más recientes.
La versión del problema en el navegador es la clásica carrera de búsqueda. La solicitud A comienza antes que la B pero termina después, y el código que muestra los resultados obtenidos muestra información desactualizada en la pantalla:
const results = await search(query);
render(results);
Ningún await adicional puede solucionar esto. La aplicación necesita una regla sobre relevancia u orden: cancelar búsquedas antiguas, etiquetar las solicitudes con una versión y descartar respuestas obsoletas, comparar identificadores antes de renderizar o aplicar verificaciones de versión donde se almacenan los datos. Es importante recordar que await organiza el flujo de control dentro de una función; no crea un orden global entre operaciones asíncronas independientes.
Las reintentos revelan lo que await nunca prometió
Los reintentos son donde un modelo mental incompleto se vuelve problemático. Empecemos con una llamada de pago:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Supongamos que la solicitud se tiempo out y un desarrollador agrega un reintento ingenuo:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
Esto supone que el intento fallido no tuvo ningún efecto. Un tiempo de espera solo significa que el cliente no recibió una respuesta a tiempo. Es posible que el servidor haya cargado la tarjeta y perdido la respuesta, o que la conexión se haya interrumpido después de que el efecto secundario ya se hubiera producido. Intentar nuevamente de forma ciega puede hacer que se cobre al cliente dos veces.
await no puede indicar si un nuevo intento es seguro. Una promesa informa si dicho intento produjo una aceptación o un rechazo que el llamante pueda observar. No indica si el sistema remoto realizó acciones irreversibles antes de ese resultado.
En el caso de operaciones con efectos secundarios, la seguridad al intentar nuevamente debe diseñarse dentro de la propia operación, generalmente a través de la idempotencia. El cliente genera una clave estable una vez por cada intento lógico de pago y la envía con cada nuevo intento:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
La clave solo es útil si el servidor la respeta: debe registrarla junto con el resultado y, cuando llegue nuevamente la misma clave, devolver el resultado original en lugar de cobrar de nuevo. La clave también debe permanecer igual en todas las reintentos de un mismo intento; generar una nueva por cada solicitud anula su propósito. La implementación difiere entre sistemas, pero el principio no: la semántica de reintentos pertenece a la operación y a los sistemas que ejecutan sus efectos secundarios, no a async/await. El manejo del lado del servidor se explica en comprendiendo las claves de idempotencia en los endpoints POST de Node.js.
De esto se deriva un hábito valioso en la programación JavaScript de producción. Cada vez que una llamada pendiente falla, mantenga separadas dos afirmaciones: “No recibí un resultado de éxito” y “la operación definitivamente no tuvo lugar”. No se trata de la misma afirmación.
Un modelo mental más pequeño y preciso
No es necesario memorizar la especificación ECMAScript para razonar bien sobre async/await. Lo que se necesita es un contrato sencillo: una función asíncrona devuelve una promesa; su cuerpo se ejecuta normalmente hasta llegar a un await; el await suspende esa función hasta que el valor esperado esté listo y luego programa su continuación.
Todo lo demás es una pregunta aparte con su propia respuesta:
- Varias operaciones: ¿cuándo comienza cada una y depende de otra? Esto le indica si la secuenciación es intencional o accidental.
try/catch protege realmente lo que se cree que protege.await no pueden hacerlo?Puntos clave
async/await está diseñado de forma intencional para ser sencillo. Hace que el flujo de control asíncrono sea legible, lo cual es extremadamente valioso, pero esa misma legibilidad hace que el código parezca más síncrono de lo que realmente es el sistema subyacente. Cuando te encuentres con un await, evita interpretarlo como “el programa espera aquí”. Considéralo una indicación: esta función se detiene hasta que llegue un valor, así que ¿qué operaciones ya están en ejecución, qué puede ejecutarse mientras tanto y qué garantías debe ofrecer tu propio diseño? Esa pregunta refleja exactamente lo que hace la sintaxis, y te lleva directamente a las decisiones de diseño que evitan errores en producción.
Lecturas relacionadas
- Async/Await vs Promises: Qué realmente difiere en el fondo — Explica las verdaderas diferencias en la ejecución, el uso de memoria y los rastros de pila entre async/await y Promises, así como cuándo recurrir a las APIs básicas de Promise.
- Conceptos comunes y erróneos sobre async/await que causan problemas 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.