Promesas y async/await: cómo funciona la asincronía en JavaScript (de verdad)
Un recorrido desde los callbacks hasta async/await, con los patrones que más usamos en proyectos reales, los errores que nos comimos y las decisiones que importan.
JavaScript es single-threaded. Un solo hilo. Eso significa que no puede hacer dos cosas al mismo tiempo — y sin embargo, hace requests de red, lee archivos, escucha clicks del usuario y sigue renderizando la UI como si nada.
¿Cómo? Con un modelo de asincronía que evolucionó mucho desde los primeros callbacks hasta lo que usamos hoy. Vamos a recorrer ese camino, pero enfocándonos en lo que importa cuando estás construyendo algo de verdad.
El punto de partida: callbacks
Antes de que existieran las promesas, la forma estándar de manejar operaciones asíncronas era pasar funciones como argumento. “Cuando termines, llamá a esta función con el resultado”:
obtenerUsuario(id, function (err, usuario) {
if (err) return manejarError(err);
obtenerPerfil(usuario.id, function (err, perfil) {
if (err) return manejarError(err);
obtenerPosts(perfil.id, function (err, posts) {
if (err) return manejarError(err);
renderizar(usuario, perfil, posts);
});
});
});
Esto se conoce como callback hell, y el problema no es solo la anidación visual. El verdadero problema es que el flujo de control se fragmenta: el manejo de errores se repite, la lógica se desparrama entre funciones anónimas, y modificar un paso intermedio se vuelve frágil.
Node.js vivió años así. La convención (err, result) funcionaba, pero escalaba muy mal.
Promesas: el primer salto real
Una Promise es un objeto que representa un valor que puede estar disponible ahora, en el futuro o nunca. Es un contenedor para un resultado asíncrono.
Tiene tres estados posibles:
pending— la operación todavía está en cursofulfilled— terminó con éxito, hay un valor disponiblerejected— falló, hay un error
Lo importante: una promesa solo cambia de estado una vez. Una vez resuelta o rechazada, queda así para siempre. Esto es una garantía que los callbacks no te dan.
const promesa = new Promise((resolve, reject) => {
// Simulamos algo que tarda
setTimeout(() => {
const exito = Math.random() > 0.3;
if (exito) {
resolve({ id: 1, nombre: "hexadevs" });
} else {
reject(new Error("No se pudo conectar"));
}
}, 1000);
});
promesa
.then((datos) => console.log("Resultado:", datos))
.catch((err) => console.error("Error:", err.message));
En la práctica, rara vez creás promesas con new Promise directamente. La mayoría de las APIs modernas ya retornan promesas: fetch, fs/promises, métodos de ORMs como Drizzle o Prisma, etc.
Encadenamiento: donde las promesas brillan
Cada .then() retorna una nueva promesa, lo que te permite encadenar operaciones de forma lineal en lugar de anidarlas:
fetch("/api/usuarios/1")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((usuario) => fetch(`/api/perfiles/${usuario.id}`))
.then((res) => res.json())
.then((perfil) => {
console.log("Perfil cargado:", perfil);
})
.catch((err) => {
// Un solo catch maneja cualquier error de la cadena
console.error("Algo falló:", err.message);
});
Una nota que a veces se pierde: el .catch() al final maneja errores de cualquier paso anterior. Si fetch falla, si el JSON es inválido, si el segundo request falla — todo llega al mismo lugar. Esto es mucho más robusto que tener un if (err) en cada callback.
Async/await: la sintaxis que cambió todo
async/await no es una feature nueva en el sentido de que haga algo que las promesas no podían. Es syntactic sugar. Pero la diferencia en legibilidad es enorme, especialmente cuando el flujo tiene lógica condicional:
async function cargarPerfil(userId) {
const resUsuario = await fetch(`/api/usuarios/${userId}`);
if (!resUsuario.ok) {
throw new Error(`No se encontró el usuario ${userId}`);
}
const usuario = await resUsuario.json();
// Solo cargamos el perfil si el usuario está activo
if (!usuario.activo) {
return { usuario, perfil: null };
}
const resPerfil = await fetch(`/api/perfiles/${usuario.id}`);
const perfil = await resPerfil.json();
return { usuario, perfil };
}
Intentá escribir esa lógica condicional con .then() encadenado. Se puede, pero es bastante más difícil de seguir.
Manejo de errores
Con async/await, los errores se manejan con try/catch. Funciona exactamente como esperarías:
async function cargarDatos() {
try {
const res = await fetch("/api/datos");
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (error) {
console.error("Error al cargar datos:", error.message);
// Podés retornar un fallback, re-lanzar, o lo que necesites
throw error;
}
}
Un detalle que vale la pena mencionar: await rechazado lanza una excepción. Si no tenés un try/catch y no hay nada que atrape la promesa rechazada, vas a tener un unhandledrejection. En Node.js, eso puede tirar el proceso.
Paralelismo: Promise.all y sus variantes
Uno de los errores más comunes que vemos — y que nos ha pasado a nosotros — es hacer operaciones en serie cuando podrían ser paralelas:
// ❌ Secuencial sin necesidad: la segunda espera a la primera
async function cargarDashboard(userId) {
const usuario = await obtenerUsuario(userId);
const notificaciones = await obtenerNotificaciones(userId);
const stats = await obtenerStats(userId);
return { usuario, notificaciones, stats };
}
// ✅ Paralelo: las tres arrancan al mismo tiempo
async function cargarDashboard(userId) {
const [usuario, notificaciones, stats] = await Promise.all([
obtenerUsuario(userId),
obtenerNotificaciones(userId),
obtenerStats(userId),
]);
return { usuario, notificaciones, stats };
}
Si cada request tarda ~200ms, la versión secuencial tarda ~600ms. La paralela tarda ~200ms. En un dashboard real con 5-10 requests, la diferencia es brutal.
El trade-off de Promise.all: si una sola falla, se rechaza todo. A veces eso es lo que querés (si no puedo cargar los datos críticos, mejor mostrar un error). Pero a veces necesitás que las que pueden fallar no bloqueen al resto.
Para eso están las variantes:
// Promise.allSettled: espera todas, sin importar si fallan
const resultados = await Promise.allSettled([
obtenerUsuario(userId),
obtenerNotificaciones(userId), // puede fallar sin drama
obtenerStats(userId), // puede fallar sin drama
]);
resultados.forEach((r) => {
if (r.status === "fulfilled") {
console.log("OK:", r.value);
} else {
console.warn("Falló:", r.reason.message);
}
});
// Promise.race: la primera que termine, gana
// Útil para timeouts manuales
const datos = await Promise.race([
fetch("/api/datos"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000)
),
]);
// Promise.any: la primera exitosa (ignora rechazos)
// Útil para redundancia entre servidores
const respuesta = await Promise.any([
fetch("https://cdn-1.example.com/config"),
fetch("https://cdn-2.example.com/config"),
]);
Errores que cometimos (y que probablemente vas a ver)
El await olvidado
// ❌ Esto retorna una promesa pendiente, no los datos
async function obtener() {
const datos = fetch("/api/datos"); // falta await
return datos;
}
TypeScript te puede proteger de esto en algunos casos, pero no siempre. Si datos es Promise<Response> y esperabas Response, algo anda mal.
Async/await secuencial por inercia
// ❌ Es muy fácil caer en esto
const usuarios = await obtenerUsuarios();
const productos = await obtenerProductos();
const config = await obtenerConfig();
Si esas tres funciones no dependen entre sí, deberían ir con Promise.all. Pero cuando escribís código de arriba a abajo, es natural ponerle await a todo. Hay que parar y preguntarse: “¿esto realmente depende de lo anterior?”
Promesas flotantes sin manejo de error
// ❌ Si esto falla, nadie se entera
actualizarCache();
// ✅ Si no te importa el resultado pero sí los errores
actualizarCache().catch((err) => {
console.error("Cache update failed:", err);
});
En Node.js, una promesa rechazada sin handler termina con un warning (y en versiones recientes, puede crashear el proceso).
Un patrón que usamos: wrapper de errores
En proyectos donde tenemos muchas funciones async, a veces usamos un helper inspirado en Go para evitar try/catch en todos lados:
async function to(promesa) {
try {
const resultado = await promesa;
return [null, resultado];
} catch (error) {
return [error, null];
}
}
// Uso:
const [err, usuario] = await to(obtenerUsuario(id));
if (err) {
console.error("No se pudo cargar el usuario:", err.message);
return;
}
console.log(usuario.nombre);
No es para todo. En un handler de Express donde ya tenés un middleware de errores, simplemente dejar que el error se propague suele ser mejor. Pero en flujos donde necesitás manejar el error de forma diferente según el paso, este patrón es muy cómodo.
Para llevar
La evolución de callbacks → promesas → async/await no fue solo cosmética. Cada paso resolvió problemas reales de control de flujo, manejo de errores y legibilidad.
Hoy, lo que usamos habitualmente:
async/awaitpara la mayoría del código asíncronoPromise.allcada vez que tenemos operaciones independientesPromise.allSettledcuando queremos tolerancia a fallos parcialestry/catchpara manejar errores, con re-lanzamiento cuando correspondenew Promisesolo cuando wrapeamos APIs basadas en callbacks (timers, streams legacy, etc.)
La asincronía en JavaScript ya no es un dolor. Pero sí requiere pensar en qué operaciones son realmente dependientes entre sí y cuáles no, en qué pasa si algo falla y en cómo evitar que una promesa rechazada pase silenciosa. Entender eso te evita bugs que son difíciles de reproducir y aún más difíciles de debuggear.