Por qué existe el JavaScript asíncrono
Las computadoras realizan muchas tareas lentamente: leer archivos, hacer consultas a bases de datos o esperar a que un servidor responda. Si JavaScript se detuviera a esperar cada una de estas acciones, la página se congelaría y los usuarios se irían. La programación asíncrona es la forma en que el lenguaje sigue funcionando mientras las tareas lentas se ejecutan en otro lugar.
El modelo mental es el de un restaurante. Un único camarero toma tu pedido, lo pasa a la cocina e inmediatamente atiende a otra mesa. Cuando tu comida está lista, la cocina toca una campana y el camarero la trae a la mesa. El camarero nunca se queda parado en la cocina mirando cómo se cocina la comida. JavaScript es ese camarero, y el event loop es la campana.
El call stack y el event loop
JavaScript tiene un único call stack. Cada llamada a una función añade un frame; cuando esta retorna, el frame se elimina. Debido a que solo hay un stack, solo puede ejecutarse una cosa a la vez.
Las API asíncronas son proporcionadas por el entorno host, no por el lenguaje. Cuando llamas a setTimeout, fetch o a una lectura de archivo, el navegador o Node.js se encarga de la espera. Cuando el trabajo termina, su callback se coloca en una cola. El event loop verifica constantemente: ¿está el call stack vacío? Si es así, toma el siguiente callback de la cola y lo ejecuta.
// order.js
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout
Hay dos detalles importantes. Primero, el código síncrono siempre termina antes de que se ejecute cualquier callback. Segundo, existen dos colas: las microtasks (callbacks de promesas) se ejecutan inmediatamente después de la tarea actual, antes que las tasks (timers, I/O). Es por eso que promise se registra en el log antes que timeout.
Callbacks
La forma original de manejar resultados asíncronos es mediante un callback: una función que pasas como argumento para que sea ejecutada más tarde.
// callback.js
function getUser(id, callback) {
setTimeout(() => {
callback({ id, name: "Ada" });
}, 300);
}
getUser(1, (user) => {
console.log(user.name);
});
Los callbacks funcionan, pero su composición es deficiente. Cuando cada paso depende del anterior, terminas anidando funciones, y el resultado es el callback hell: código que avanza diagonalmente a través de la pantalla y hace que el manejo de errores sea tedioso.
// hell.js
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
// three levels deep and counting
});
});
});
Promises
Una Promise es un objeto que representa un valor que puede que aún no exista. Se encuentra en uno de estos tres estados:
- pending — el trabajo aún se está ejecutando.
- fulfilled — terminó exitosamente con un valor.
- rejected — falló por alguna razón.
Una vez resuelta, una promise nunca cambia de estado. Puedes adjuntar manejadores con then, catch y finally.
// promise.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada" });
else reject(new Error("Invalid id"));
}, 300);
});
}
getUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))
.finally(() => console.log("done"));
El encadenamiento es la ventaja clave. Cada then devuelve una nueva promise, por lo que en lugar de anidar, puedes aplanar la secuencia y manejar cada error en un único catch al final.
// chain.js
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error));
Combinando promesas
Cuando intervienen varias operaciones asíncronas, el constructor Promise proporciona combinadores.
// combine.js
const [user, posts, settings] = await Promise.all([
getUser(1),
getPosts(1),
getSettings(),
]);
const results = await Promise.allSettled([getUser(1), getUser(-1)]);
const fastest = await Promise.race([getFromCache(), getFromNetwork()]);
const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);
Promise.all ejecuta todo en paralelo y falla si cualquier promesa es rechazada. Promise.allSettled espera a que todas terminen y reporta cada resultado. Promise.race se resuelve con la primera promesa que se liquide, y Promise.any se resuelve con la primera que tenga éxito. Elegir la opción correcta cambia drásticamente el comportamiento de los errores.
async y await
async y await son sintaxis construidas sobre las promesas. Marcar una función como async hace que siempre devuelva una promesa. Dentro de ella, await pausa la función hasta que una promesa se resuelve, y luego continúa con el valor resuelto, sin bloquear el resto del programa.
// await.js
async function loadDashboard() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load dashboard", error);
throw error;
}
}
Esto se lee como código síncrono, que es precisamente el objetivo. Los errores se gestionan con los bloques try/catch habituales, y finally sigue ejecutándose. La función devuelve una promesa, por lo que quienes la llamen pueden hacerle un await o encadenar .then como de costumbre.
Secuencial vs paralelo
Un error sutil pero costoso es esperar tareas independientes una por una. Cada await espera a la anterior, por lo que el tiempo total es la suma de todas. Cuando las tareas no dependen entre sí, inicíalas simultáneamente.
// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");
// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);
Usa await secuenciales solo cuando un paso posterior realmente necesite el resultado de uno anterior, como obtener un usuario antes de obtener las publicaciones de dicho usuario.
Manejo de errores en código async
Las promesas rechazadas que nadie gestiona se convierten en unhandled rejections, lo que puede provocar la caída de procesos de Node.js y llenar la consola en los navegadores.
// errors.js
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
} catch (error) {
showMessage("Could not load data. Please try again.");
} finally {
hideSpinner();
}
Prioriza el uso de try/catch alrededor de las llamadas con await, lanza objetos Error (no strings) y asegúrate siempre de gestionar el error o relanzarlo deliberadamente. La guía de Manejo de Errores profundiza en estrategias y mensajes orientados al usuario.
Errores comunes
- Olvidar
await. La función devuelve una promesa, no el valor, por lo que la siguiente línea se ejecuta demasiado pronto. forEachcon callbacks asíncronos.forEachignora las promesas devueltas; utilizafor...ofoPromise.allconmap.- Usar await en un bucle cuando se podría hacer en paralelo. Funciona, pero es innecesariamente lento.
- No retornar dentro de
then. La falta de unreturnrompe la cadena y hace que se pierda el valor. - Asumir el orden. Solo
awaity el orden de las microtareas garantizan la secuencia; las tareas concurrentes terminan cuando terminan. - Silenciar errores con un
catchvacío, ocultando el bug que necesitabas ver.
Mejores prácticas
- Prefiere
async/awaiten lugar de cadenas largas de.thenpara mejorar la legibilidad. - Ejecuta tareas independientes en paralelo con
Promise.alloPromise.allSettled. - Maneja siempre los rechazos, aunque sea solo para registrarlos y volver a lanzarlos.
- Mantén las funciones async pequeñas y asígnales una sola responsabilidad.
- Usa
Promise.allSettledcuando los resultados parciales sean aceptables. - Cancela el trabajo que ya no necesites con un
AbortController. - Muestra estados de carga y de error para que los usuarios entiendan qué está sucediendo.
Próximos pasos
Ya tienes el kit de herramientas asíncronas completo. Ponlo en práctica con la Fetch API, donde las promesas y await impulsan cada solicitud de red, y refuérzalo con los patrones de Manejo de Errores.