Refactoreo de cadenas 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().
Fuentes: Async/Await in JavaScript: Writing Cleaner Asynchronous Code
Las promesas rescataron a JavaScript de las llamadas anidadas, pero una cadena de pasos .then() dependientes sigue pareciéndose más a un sistema de tuberías que a una secuencia simple de instrucciones. async/await cierra esa brecha al permitir que el código asíncrono se vea como código ordinario de arriba hacia abajo, manteniendo las promesas en el fondo. Esta guía muestra cómo convertir una cadena de promesas de forma segura, qué es lo que realmente detiene await, cuándo los await secuenciales desperdician tiempo y cómo cambia el manejo de errores, para que pueda refactorizar código existente sin alterar su funcionamiento.
De una cadena de then a una función lineal
Aquí hay una cadena típica en la que cada paso depende del resultado del anterior: cargar a un usuario, luego sus pedidos y después los recibos de esos pedidos, con un manejador para cualquier error.
getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.then(receipts => {
console.log(receipts);
})
.catch(err => handleError(err));
Esto es correcto y mucho mejor que los callbacks anidados, pero aún así hay que seguir visualmente la cadena para rastrear los datos. async/await, estandarizado en ES2017, fue diseñado para eliminar ese último obstáculo. La misma lógica reescrita se ve así:
async function loadData(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
console.log(receipts);
} catch (err) {
handleError(err);
}
}
El comportamiento coincide con la cadena: cada paso comienza solo después de que finalice el anterior, y cualquier error termina en el único manejador de errores. Lo que cambió es la legibilidad. Cada valor intermedio ahora tiene un nombre, y el flujo se sigue de arriba hacia abajo.
Tenga siempre en mente un concepto fundamental: async/await no reemplaza a las promesas. Es simplemente un formato sintáctico adicional sobre ellas. Las promesas siguen realizando el trabajo asíncrono; la sintaxis solo modifica cómo se escribe y se lee.
Qué cambia con la palabra clave async
Colocar async al principio de una función tiene dos efectos. La función a continuación devuelve un objeto simple:
async function getUser(id) {
return { id, name: "Aarav" };
}
Siempre devuelve una promesa
Aunque getUser devuelve un objeto normal, al llamarlo se obtiene una promesa que se resuelve con ese objeto. Por lo tanto, los llamantes pueden tratarla como cualquier otra promesa:
getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }
Si la función lanza un error, la promesa devuelta se rechaza en lugar de que el error se propague de forma síncrona. Es fácil olvidarlo cuando una función que antes era síncrona se convierte en async: todos los llamantes que antes utilizaban directamente su valor de retorno ahora reciben una promesa y deben ser actualizados.
Permite usar await en su cuerpo
await es válido dentro de una función async. El JavaScript moderno también permite el uso de await en el nivel superior en los módulos ES, lo cual es conveniente para scripts e inicialización de módulos, pero en funciones regulares aún se necesita async.
Qué detiene realmente await
Una expresión await devuelve el control hasta que su promesa se resuelva de una u otra manera. El éxito hace que la expresión genere el valor resuelto; el fracaso hace que esa línea lance un error, al igual que lo haría una instrucción throw. El ejemplo muestra un mensaje, espera al usuario y luego registra el resultado:
async function loadUser() {
console.log("Fetching user...");
const user = await getUser(1); // pauses here until getUser's promise settles
console.log("Got user:", user);
}
La salida aparece en este orden:
Fetching user...
Got user: { id: 1, name: "Aarav" }
El detalle importante es lo que significa “pausar”. Solo esta función se suspende, en esa línea exacta. El resto del programa, incluido otro código, los temporizadores y las solicitudes entrantes en un servidor, sigue ejecutándose. Cuando la promesa se resuelve, la función reanuda desde donde se detuvo, con el valor resuelto asignado a la variable.
await secuenciales y su costo oculto
Varios await seguidos se ejecutan uno tras otro:
async function loadDashboard(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
}
Cada línea espera su propio paso antes de que comience el siguiente, lo cual reproduce el comportamiento en cadena de .then() sin la cadena. Esto es correcto cuando cada paso necesita el resultado anterior, como aquí: no se pueden obtener los pedidos antes de conocer al usuario.
Los problemas surgen cuando se aplica el mismo estilo a operaciones que no dependen unas de otras. En el cargador de panel de control mostrado a continuación, el usuario, las notificaciones y las recomendaciones son independientes, pero cada solicitud espera a la anterior:
async function loadDashboard() {
const user = await getUser();
const notifications = await getNotifications();
const recommendations = await getRecommendations();
return { user, notifications, recommendations };
}
El tiempo total se convierte en la suma de las tres solicitudes en lugar del tiempo que tarda la más lenta.
Comenzar tareas independientes al mismo tiempo
Dado que las tres llamadas son independientes, se pueden iniciar al mismo tiempo y esperar a que todas finalicen con Promise.all():
async function loadDashboard() {
const [user, notifications, recommendations] = await Promise.all([
getUser(),
getNotifications(),
getRecommendations()
]);
return { user, notifications, recommendations };
}
Todas las tres solicitudes se están procesando simultáneamente, y el desestructurado permite obtener los resultados en el mismo orden que el array de entrada. Un aspecto importante a conocer: Promise.all() se cumple solo cuando todas las promesas se cumplen, y rechaza inmediatamente en cuanto alguna de ellas lo haga, sin esperar al resto. Si desea obtener todos los resultados independientemente de las fallas, Promise.allSettled() es la opción más adecuada. Las diferencias entre estos combinadores se tratan en elegir entre Promise.all, Promise.race y await secuenciales.
Manejo de errores con try y catch
Con async/await, el conocido bloque try/catch reemplaza a .catch(), de modo que las fallas síncronas y asíncronas comparten finalmente una misma estructura:
async function loadUser(id) {
try {
const user = await getUser(id);
console.log(user);
} catch (err) {
console.log("Failed to load user:", err.message);
}
}
Si la promesa devuelta por getUser(id) se rechaza, el control pasa directamente a catch, tal como ocurriría con un error generado por código síncrono. Cabe señalar que esta función maneja el error y luego finaliza normalmente, por lo que su propia promesa se cumple con undefined. Los llamantes no pueden saber que la carga falló. Cuando un llamante necesite reaccionar, debe volver a lanzar el error después de registrarlo o devolver un resultado explícito en su lugar.
Un manejador para varias etapas
Un único bloque try puede abarcar toda una secuencia de await:
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
} catch (err) {
console.log("Something failed in the dashboard load:", err.message);
}
}
Cuando falla cualquier paso, la ejecución pasa directamente a catch, por lo que no es necesario tener un manejador para cada paso. Esto refleja el efecto de centralización que tenía .catch() al final de una cadena. Cuando diferentes pasos requieren procedimientos de recuperación distintos, por ejemplo intentar nuevamente solo la solicitud de recibos, anide un try más específico alrededor de ese paso.
Comparación de ambos estilos lado a lado
Aquí está el mismo cargador en ambas formas:
// Promises
function loadDashboard(id) {
return getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.catch(err => console.log("Failed:", err.message));
}
// async/await
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return await getReceipts(orders);
} catch (err) {
console.log("Failed:", err.message);
}
}
Ninguna de las versiones modifica el trabajo asíncrono subyacente. La elección depende de cuál sea más fácil de leer y mantener por parte de los desarrolladores, y en el caso de lógicas con múltiples pasos, generalmente async/await es la opción ganadora. Un punto sutil en la segunda versión: return await getReceipts(orders) dentro de try es importante. Escribir return getReceipts(orders) sin await haría que la promesa fuera devuelta antes de que se resolviera, por lo que cualquier rechazo en ese último paso escaparía completamente del bloque catch.
Preguntas frecuentes
¿Bloquea await toda la aplicación?
No. Solo suspende la función asíncrona que lo contiene. El resto del código, incluidas otras solicitudes, continúa ejecutándose mientras esa función espera.
¿Se puede usar await fuera de una función asíncrona?
No está disponible en funciones regulares. La excepción es el await de nivel superior en módulos ES, que funciona a nivel de módulo pero no dentro de funciones comunes no asíncronas.
¿Es async/await más rápido que las promesas?
No. Está basado en promesas, por lo que no hay ventaja de velocidad al cambiar la sintaxis. La ventaja radica en la legibilidad y mantenibilidad. Las verdaderas mejoras de rendimiento provienen de ejecutar tareas independientes de forma concurrente, no de la palabra clave.
¿Qué sucede sin try/catch?
Si una promesa en espera se rechaza y no hay nada que la capture, la propia promesa de la función asíncrona también se rechaza. Si tampoco hay ningún llamante que maneje ese rechazo, el entorno de ejecución informa sobre un rechazo no manejado, lo que en las versiones actuales de Node.js termina el proceso por defecto. Maneje los errores con try/catch dentro de la función o con .catch() donde se llame a dicha función. Para conocer más casos de este tipo, consulte conceptos errorosos comunes sobre asincrono que causan problemas en producción.
Puntos clave
async/awaitcambia la forma en que se escribe el código asíncrono, no la forma en que se ejecuta; las promesas siguen siendo las responsables de realizar el trabajo.- Una función
asyncsiempre devuelve una promesa, por lo que convertir una función aasyncmodifica su comportamiento para todos los llamantes.
await solo suspende su propia función, nunca todo el programa.Promise.all() o Promise.allSettled().try/catch alrededor de varios await centraliza el manejo de errores; recuerde volver a lanzar la excepción cuando quienes llaman necesiten saberlo, y usar return await dentro de try.Lecturas relacionadas
- Arreglando errores en el manejo de Async/Await en código producción de Node.js — Conozca cinco errores comunes en el manejo de async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.
- Qué realmente garantiza async/await y qué deja en manos del usuario — Entienda qué es lo que realmente suspende await, cómo evitar solicitudes serializadas y por qué los errores, la cancelación, el ordenamiento y las reintentos requieren diseños que vayan más allá de async/await.