Async/Await frente a Promises: ¿Qué difiere realmente en su funcionamiento interno?
¿En qué difieren realmente async/await y Promises en JavaScript: el orden de ejecución, la memoria, los rastros de pila y cuándo las APIs básicas de Promise siguen siendo las mejores en Node.js?
Fuentes: Async/Await vs Promises — The Real Difference Nobody Explains Clearly
Basta ya de decir “es solo azúcar sintáctica”. Aquí está lo que realmente sucede en el fondo con la ejecución, la memoria, los rastros de pila y la concurrencia.
Pídale a un grupo completo de desarrolladores de JavaScript que expliquen en qué se diferencian las Promises de async/await, y la mayoría repetirá la misma frase que ya ha escuchado cientos de veces:
“Async/await no es más que una capa estética encima de las Promises; simplemente permite que el código asíncrono se lea como si se ejecutara de forma síncrona.”
Esa afirmación no está del todo equivocada, pero es un atajo que oculta más de lo que revela. Describir async/await como “solo azúcar sintáctica” es algo similar a describir una transmisión automática como “solo un panel decorativo atornillado a una caja de cambios manual”. Esto pasa por alto cómo funciona realmente el mecanismo subyacente, cómo se propagan los errores a través del sistema, cómo se asigna la memoria y por qué elegir un estilo en lugar de otro puede cambiar significativamente el comportamiento de tu aplicación una vez que está en producción.
Si los dos enfoques fueran realmente intercambiables a todos los niveles, considera estas preguntas:
- ¿Por qué las trazas de errores se ven completamente diferentes cuando un fallo surge dentro de un
awaitfrente a uno dentro de un.catch()?
.then() y una función async equivalente?Promise incluso en bases de código construidas íntegramente con async/await?Dejemos de lado las explicaciones simplificadas y analicemos qué ocurre realmente dentro del entorno de ejecución de JavaScript cuando se ejecuta código asíncrono.
1. El modelo mental fundamental: Empujar vs. Pausar
Para comprender por qué estos dos estilos se sienten tan diferentes al escribirlos y luego depurarlos, es necesario entender los modelos de ejecución distintos en los que cada uno se basa.
RAW PROMISE (.then CHAIN) ASYNC / AWAIT (COROUTINE)
=========================== =========================
+-----------------------+ +-----------------------+
| Initiate Task | | Initiate Task |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Register Callback | | Yield & Pause Frame |
| (Handler pushed away) | | (Thread freed up) |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Main Stack Clears | | Resume on Resolution |
| (Context lost) | | (Context restored) |
+-----------------------+ +-----------------------+
El modelo de Promise: Registro de callbacks (Empujar)
Una Promise es, en esencia, un objeto que representa el resultado futuro —éxito o fracaso— de alguna tarea asíncrona.
Tomemos una cadena típica de .then():
function getUserData(userId) {
return fetchUser(userId)
.then(user => fetchOrders(user.orderId))
.then(orders => calculateTotal(orders))
.catch(err => handleError(err));
}
En realidad, nada aquí se detiene. La función getUserData se ejecuta de forma síncrona, desde el inicio hasta el final, en cuestión de microsegundos. Lo que realmente hace es crear una Promise, adjuntar funciones de callback que se ejecutarán una vez esas Promises estén resueltas, y luego salir inmediatamente de la pila de llamadas.
Más tarde, una vez que finaliza la solicitud de red, el bucle de eventos extrae la función de callback registrada de la cola de microtareas y la coloca en una pila de llamadas completamente nueva y, por lo demás, vacía. La función original que inició esta cadena ya ha desaparecido por completo.
El modelo Async/Await: Suspensión de corutinas (Pausa y reanudación)
async/await se implementa sobre la base de los generadores de ECMAScript y las mecánicas de corutinas.
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.orderId);
return calculateTotal(orders);
} catch (err) {
handleError(err);
}
}
Cuando el motor llega a una expresión await, no descarta la función; en su lugar, suspende ese marco de pila específico en su sitio.
- El control vuelve al hilo principal, liberándolo para realizar otras tareas: volver a dibujar la interfaz de usuario en un navegador o manejar otras solicitudes entrantes en Node.js.
- Todo lo relacionado con el estado local de la función —sus variables, su ámbito y su posición— se mantiene intacto en la memoria.
- Una vez que la Promise esperada se resuelve, el motor introduce el valor resultante de nuevo en ese mismo contexto de ejecución y continúa la ejecución exactamente desde donde se detuvo.
Esta única diferencia —entregar una función de callback separada para ejecutarse más tarde, frente a congelar y posteriormente reanudar un marco de ejecución— explica casi todas las diferencias prácticas que notará entre los dos estilos.
2. La anatomía de una pila de llamadas: por qué la depuración es diferente
Encontrar errores en entornos de producción ya es una de las partes más difíciles al trabajar con JavaScript, y la diferencia entre cadenas de Promise y async/await se hace evidente en cuanto comienza a leer los registros.
Así se ve un rastro de pila de Promise
Tomemos una cadena de Promise donde la falla ocurre a varios niveles dentro de una llamada de red:
function stepOne() {
return stepTwo();
}
function stepTwo() {
return stepThree();
}
function stepThree() {
return fetch('https://invalid-url-domain-does-not-exist.xyz')
.then(res => res.json());
}
stepOne().catch(err => console.error(err.stack));
Cuando ocurre el error, el rastro resultante suele verse así:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:5)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
¿Qué falta? Ni stepOne ni stepTwo aparecen en ningún lugar de ese rastro.
Para cuando la llamada a la red falló realmente, tanto stepOne como stepTwo ya habían regresado hacía tiempo y sido eliminados de la pila. El código de manejo de errores se ejecutó en un nuevo ciclo de microtareas, completamente desconectado del lugar donde originó la cadena de llamadas. Si stepThree es una utilidad compartida que se invoca desde cincuenta lugares diferentes en su código, el rastro no le permite saber qué llamante provocó esta falla específica.
Así se ve un rastro de pila con Async/Await
Ahora reescriba la misma lógica utilizando async/await:
async function stepOne() {
return await stepTwo();
}
async function stepTwo() {
return await stepThree();
}
async function stepThree() {
const res = await fetch('https://invalid-url-domain-does-not-exist.xyz');
return await res.json();
}
(async () => {
try {
await stepOne();
} catch (err) {
console.error(err.stack);
}
})();
Aquí está el rastro que genera:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:21)
at async stepTwo (<anonymous>:6:10)
at async stepOne (<anonymous>:2:10)
at async <anonymous>:15:5
Dado que el motor suspende el marco en lugar de desmontarlo, los motores modernos de JavaScript —como V8 en Chrome y Node.js— pueden reconstruir el historial de llamadas asíncronas prácticamente sin costo en tiempo de ejecución. El motor lleva un registro de la continuación a través de cada punto de suspensión, por lo que el rastro muestra todo el camino que condujo a la llamada fallida dentro de stepThree.
3. La trampa de “Return Await”: un caso límite crucial
Existe un desacuerdo de larga data sobre si return await promise es redundante en comparación con escribir simplemente return promise.
Afuera de un bloque try/catch, devolver la Promise directamente te ahorra un ciclo adicional de microtareas, por lo que return promise es técnicamente más eficiente. Pero dentro de un try/catch, esa sola palabra — await — cambia por completo la forma en que se capturan los errores.
La versión defectuosa
async function fetchUserData(userId) {
try {
// BUG: Returning the promise directly without awaiting it
return api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Medio segundo después, cuando la Promise realmente se rechaza, el bloque try/catch ya ha completado su ejecución. La cláusula catch nunca se activa, porque ya no forma parte de la ruta de ejecución activa. En su lugar, el rechazo escapa por completo a tu manejo local de errores y aparece como un rechazo no manejado en algún punto de la cadena de llamadas, allí donde se invocó fetchUserData.
async function fetchUserData(userId) {
try {
// CORRECT: Await forces resolution BEFORE exiting the try block
return await api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Agregar await soluciona este problema porque indica al motor que se detenga en esa línea y permanezca dentro del bloque try actual hasta que la Promise se resuelva. Si esta se rechaza, la ejecución permanece en ese contexto el tiempo suficiente para que el bloque catch la capture, en lugar de permitir que la función regrese antes incluso de que ocurra el error.
4. Ámbito y estado compartido: Evitar la “gimnasia de ámbitos”
Las cadenas anidadas de Promise se vuelven especialmente problemáticas cuando un paso posterior necesita datos generados por uno anterior.
Imagínese que necesita obtener información de un usuario, luego su perfil, y finalmente generar un recibo que depende de los datos de ambos pasos:
function generateReceipt(userId) {
return getUser(userId)
.then(user => {
return getProfile(user.profileId)
.then(profile => {
// You are forced to nest here just to access `user` and `profile` together
return createReceipt(user, profile);
});
});
}
Para evitar la forma piramidal, los desarrolladores suelen recurrir a declarar variables fuera de la cadena para que los callbacks .then() posteriores puedan acceder a ellas:
function generateReceipt(userId) {
let tempUser; // Leaking scope variable
return getUser(userId)
.then(user => {
tempUser = user;
return getProfile(user.profileId);
})
.then(profile => {
return createReceipt(tempUser, profile);
});
}
async/await elimina la necesidad de este trabajo alternativo, ya que cada variable que se declara permanece en el mismo ámbito a nivel de función durante toda la duración de la operación:
async function generateReceipt(userId) {
const user = await getUser(userId);
const profile = await getProfile(user.profileId);
return createReceipt(user, profile);
}
No hay variable externa que predeclarar, ni estructura de callback anidada, y tampoco es necesario rastrear la cadena para averiguar de dónde proviene un valor.
5. Rendimiento y concurrencia: El error secuencial
async/await hace que el código sea más fácil de leer, pero abre la puerta a un error costoso: serializar involuntariamente operaciones que podrían ejecutarse al mismo tiempo.
Los desarrolladores que pasan de cadenas de Promise a async/await a menudo terminan escribiendo algo similar sin darse cuenta del costo en rendimiento:
// SLOW: 3 sequential network requests (Waterfall)
async function loadDashboardData(userId) {
const user = await fetchUser(userId); // Takes 200ms
const posts = await fetchPosts(userId); // Takes 200ms (waits for user)
const stats = await fetchStats(userId); // Takes 200ms (waits for posts)
return { user, posts, stats }; // Total time: 600ms
}
Dado que cada await bloquea la función hasta que esa llamada se resuelve, fetchPosts ni siquiera comenzará hasta que fetchUser haya finalizado por completo, a pesar de que fetchPosts y fetchStats no tienen ninguna dependencia en el resultado de fetchUser.
SEQUENTIAL (Slow Waterfall):
[-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
CONCURRENT (Optimized):
[-- fetchUser (200ms) --]
[-- fetchPosts (200ms) --]
[-- fetchStats (200ms) --] = 200ms (Total)
La solución: combinar async/await con combinadores de Promise
async/await no es un sustituto de los métodos útiles de Promise; está diseñado para usarse junto con ellos.
// FAST: All 3 requests start immediately in parallel
async function loadDashboardData(userId) {
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchStats(userId)
]);
return { user, posts, stats }; // Total time: ~200ms
}
6. Bucles e iteración: la trampa de los métodos de array
Una fuente común de errores en los códigos actuales en JavaScript proviene de combinar callbacks asíncronos con métodos de array como .forEach(), .map() o .filter().
async function saveUsers(users) {
users.forEach(async (user) => {
await db.users.insert(user);
});
console.log("All users saved!"); // LIES: This logs BEFORE the database calls finish!
}
Esto falla porque Array.prototype.forEach es inherentemente síncrono: nunca verifica qué devuelve la función de callback. Aunque pasar una función asíncrona a forEach hace que cada llamada genere una Promise, forEach no cuenta con mecanismo alguno para esperar esas Promises; simplemente pasa directamente a la siguiente iteración.
Existen dos alternativas correctas, dependiendo de lo que realmente necesite.
La opción A ejecuta el trabajo uno por uno utilizando un bucle for...of, lo cual es apropiado cuando el orden es importante o si necesita evitar sobrecargar una API con límite de frecuencia:
async function saveUsersSequentially(users) {
for (const user of users) {
await db.users.insert(user);
}
console.log("All users saved safely in order.");
}
La opción B ejecuta todo de forma concurrente combinando .map() con Promise.all, lo cual es adecuado en casos donde las operaciones son independientes entre sí:
async function saveUsersConcurrently(users) {
await Promise.all(users.map(user => db.users.insert(user)));
console.log("All users saved simultaneously.");
}
7. Cuando las Promises sin procesamiento siguen siendo mejores
async/await debería ser la herramienta principal para la lógica empresarial lineal, pero existen situaciones arquitectónicas específicas en las que trabajar directamente con Promises es la mejor opción.
La primera es el trabajo en segundo plano de tipo “envía y olvida” — situaciones en las que se desea activar un efecto secundario sin hacer que quien lo llama espere por él:
async function handleUserSignup(req, res) {
const user = await db.createUser(req.body);
// Non-blocking: Fire and handle errors without awaiting
sendWelcomeEmail(user.email).catch(err => {
logger.error("Failed to send welcome email in background", err);
});
// Respond immediately without waiting for the email service
return res.json({ status: "success", user });
}
La segunda es la gestión de escenarios de concurrencia más complejos, donde los métodos combinadores dedicados para Promises ofrecen un comportamiento diseñado específicamente para ello:
Promise.race()permite interrumpir llamadas a la red lentas mediante un tiempo de espera.Promise.allSettled()ejecuta varias tareas independientes e informa sobre todas ellas, tanto las exitosas como las fallidas.Promise.any()se resuelve tan pronto como alguna de las Promises proporcionadas tiene éxito, lo cual es útil para estrategias de respaldo en múltiples regiones o fuentes.
// Timeout a network request after 5 seconds
const data = await Promise.race([
fetch('https://api.example.com/data').then(r => r.json()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), 5000)
)
]);
El tercer escenario consiste en crear un puente alrededor de APIs heredadas basadas en callbacks, como fs.readFile de Node o un SDK más antiguo, lo cual requiere construir explícitamente un new Promise():
function readConfigFile(path) {
return new Promise((resolve, reject) => {
legacyLib.readFile(path, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
8. Comparación resumida
El veredicto
async/await no es solo una sintaxis estética añadida sobre Promises.
Aunque sigue dependiendo de Promises en el fondo para representar la finalización, transforma el código asíncrono de una cadena de registros de callbacks en algo más similar a una corutina interrumpible. Ese cambio es lo que permite obtener trazas de pila significativas, un manejo de errores a nivel de lenguaje mediante try/catch, y un ámbito léxico sencillo.
Para familiarizarse con el JavaScript asíncrono moderno, lo esencial es saber cuándo el estilo lineal y fácil de leer de async/await se adapta a tu lógica secuencial, y cuándo debes recurrir a los combinadores básicos de Promise — Promise.all, Promise.allSettled, Promise.race — para gestionar la concurrencia de manera efectiva.
Lecturas relacionadas
- Arreglando errores en el manejo de errores de Async/Await en código producciónal de Node.js — Aprende cinco errores comunes al manejar errores con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.