Cuando empiezas a programar en JavaScript, tarde o temprano te topas con un concepto que cambia por completo la forma en que entiendes el lenguaje: las Promesas. Si alguna vez has visto código con .then(), .catch() o la palabra async, estabas mirando Promesas aunque no lo supieras.
En esta guía vamos a ver qué son las Promesas desde cero, por qué existen, cómo funcionan por dentro y cómo usarlas correctamente en proyectos reales. Al terminar, entenderás no solo la sintaxis sino el modelo mental detrás de todo el código asíncrono en JavaScript moderno.
En este artículo:
- El problema del código asíncrono
- ¿Qué es exactamente una Promesa?
- Los tres estados de una Promesa
- Cómo crear Promesas con new Promise()
- Consumir Promesas: .then(), .catch() y .finally()
- Encadenar Promesas
- Promise.all, Promise.race y Promise.allSettled
- Async/await: la sintaxis moderna
- Errores comunes que todos cometen
El problema del código asíncrono
Para entender por qué existen las Promesas, primero hay que entender el problema que resuelven. JavaScript es un lenguaje de un solo hilo (single-threaded). Eso significa que solo puede hacer una cosa a la vez. Si una operación tarda mucho, por ejemplo, cargar datos de un servidor, leer un archivo, o esperar una respuesta de una base de datos, el programa tendría que quedarse completamente bloqueado esperando.
Para evitar ese bloqueo, JavaScript usa un modelo asíncrono basado en callbacks. La idea es simple: en lugar de esperar, le dices al programa «cuando termines esta tarea, ejecuta esta función». Eso funciona bien para casos sencillos, pero en cuanto tienes varias operaciones que dependen unas de otras, el código se convierte en algo conocido como callback hell o pirámide de la muerte.
// El infame "callback hell" — difícil de leer y mantener
obtenerUsuario(id, function(usuario) {
obtenerPedidos(usuario.id, function(pedidos) {
obtenerDetalles(pedidos[0].id, function(detalles) {
calcularTotal(detalles, function(total) {
mostrarFactura(total, function(resultado) {
console.log('Hecho');
// ... y así hasta el infinito
});
});
});
});
});
Este código es funcional pero es una pesadilla. Es difícil de leer, de depurar y de modificar. Cada nivel de anidado añade complejidad cognitiva. Si hay un error en cualquier punto, rastrear el origen es complicado. Este es exactamente el problema que las Promesas vinieron a solucionar.
¿Qué es exactamente una Promesa?
Una Promesa en JavaScript es un objeto que representa un valor que puede estar disponible ahora, en el futuro, o nunca. Es literalmente una «promesa» de que en algún momento tendrás un resultado, aunque no sepas exactamente cuándo.
Piénsalo así: cuando haces un pedido en un restaurante, el camarero no se queda de pie esperando a que la cocina termine tu plato. Te da un número o un aviso y vuelve a hacer otras cosas. Cuando tu pedido está listo, te avisan. La Promesa es ese mecanismo de aviso: te permite seguir haciendo otras cosas mientras esperas que algo termine, y cuando termine recibirás la notificación.
A nivel técnico, una Promesa es un objeto con métodos especiales (.then(), .catch(), .finally()) que te permiten suscribirte al resultado cuando esté disponible. La operación asíncrona ocurre en segundo plano, y cuando termina, la Promesa notifica a los suscriptores.
Los tres estados de una Promesa
Una Promesa puede estar en uno de tres estados posibles, y esta es una de las partes más importantes para entender cómo funcionan:
Pendiente (pending): es el estado inicial. La operación asíncrona aún no ha terminado. La Promesa está esperando a que algo pase. En este momento no tienes ni un resultado ni un error.
Resuelta (fulfilled): la operación terminó con éxito. La Promesa tiene un valor. Cuando una Promesa se resuelve, se ejecutan los callbacks que registraste con .then().
Rechazada (rejected): la operación falló. La Promesa tiene una razón del fallo, normalmente un objeto Error. Cuando una Promesa se rechaza, se ejecutan los callbacks que registraste con .catch().
Una vez que una Promesa pasa de pendiente a resuelta o rechazada, su estado no puede cambiar. Una Promesa resuelta no puede volver a ser pendiente ni rechazarse. Esto hace que el comportamiento sea predecible y consistente.
Cómo crear Promesas con new Promise()
Para crear una Promesa, usas el constructor new Promise(), que recibe una función llamada executor. Esa función recibe dos parámetros: resolve y reject. Llamas a resolve cuando la operación termina bien y a reject cuando falla.
// Creando una Promesa básica
const miPromesa = new Promise((resolve, reject) => {
// Simulamos una operación asíncrona con setTimeout
setTimeout(() => {
const exito = true;
if (exito) {
resolve('Operación completada con éxito');
} else {
reject(new Error('Algo salió mal'));
}
}, 2000);
});
// La Promesa está en estado "pending" en este punto
console.log(miPromesa); // Promise { }
El executor se ejecuta inmediatamente cuando creas la Promesa. El trabajo asíncrono real (en este caso el setTimeout) ocurre dentro del executor. Cuando ese trabajo termina, llamas a resolve con el resultado o a reject con el error.
Un ejemplo más realista sería envolver una llamada a una API antigua que usa callbacks:
// Envolviendo una función con callback en una Promesa (promisification)
function cargarImagenPromesa(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('No se pudo cargar la imagen: ' + url));
img.src = url;
});
}
// Ahora podemos usarla con .then() y .catch()
cargarImagenPromesa('https://ejemplo.com/foto.jpg')
.then(img => document.body.appendChild(img))
.catch(error => console.error(error.message));
Consumir Promesas: .then(), .catch() y .finally()
Una vez que tienes una Promesa, la consumes con tres métodos principales que se comportan como suscriptores a los diferentes estados:
.then(onFulfilled, onRejected): el método principal. Recibe hasta dos callbacks. El primero se ejecuta cuando la Promesa se resuelve y recibe el valor. El segundo (opcional) se ejecuta cuando se rechaza. En la práctica, normalmente solo pasas el primero y usas .catch() para los errores.
fetch('https://jsonplaceholder.typicode.com/users/1')
.then(response => response.json())
.then(usuario => {
console.log('Nombre:', usuario.name);
console.log('Email:', usuario.email);
})
.catch(error => {
console.error('Error al cargar usuario:', error.message);
})
.finally(() => {
// Se ejecuta siempre, haya error o no
console.log('Petición finalizada');
ocultarSpinner();
});
.catch(onRejected): es equivalente a .then(null, onRejected). Captura cualquier error que haya ocurrido en la cadena de Promesas, ya sea un rechazo explícito o una excepción lanzada con throw dentro de un .then().
.finally(onFinally): se ejecuta siempre, independientemente de si la Promesa se resolvió o se rechazó. Es ideal para limpiar recursos, ocultar spinners de carga o cualquier acción que deba ocurrir sin importar el resultado.
Un detalle importante: todos estos métodos devuelven una nueva Promesa, lo que permite encadenarlos. El valor que devuelves en un .then() se convierte en el valor de la siguiente Promesa en la cadena.
Encadenar Promesas
El encadenamiento de Promesas es lo que hace que el código asíncrono sea legible. En lugar de anidar callbacks, pones cada paso en un .then() separado, y los pasos se ejecutan en orden secuencial, uno después del otro.
// Sin encadenamiento (versión callback hell)
obtenerUsuario(1, function(usuario) {
obtenerPedidos(usuario.id, function(pedidos) {
mostrarPedidos(pedidos);
});
});
// Con encadenamiento de Promesas (mucho más limpio)
obtenerUsuario(1)
.then(usuario => obtenerPedidos(usuario.id))
.then(pedidos => mostrarPedidos(pedidos))
.catch(error => manejarError(error));
El truco está en lo que devuelves dentro de cada .then(). Si devuelves un valor normal, ese valor se envuelve automáticamente en una Promesa resuelta y pasa al siguiente .then(). Si devuelves una Promesa, la cadena espera a que esa Promesa se resuelva antes de continuar. Esto es lo que permite que cada paso sea asíncrono de forma transparente.
// Transformación de datos en una cadena
fetch('/api/productos')
.then(response => {
if (!response.ok) throw new Error('Error HTTP ' + response.status);
return response.json(); // devuelve una Promesa
})
.then(productos => {
// "productos" es el array ya parseado
return productos.filter(p => p.stock > 0); // devuelve un valor normal
})
.then(disponibles => {
// "disponibles" son los productos con stock
return disponibles.map(p => ({ ...p, precio: p.precio * 1.21 })); // IVA incluido
})
.then(conIVA => {
renderizarCatalogo(conIVA);
})
.catch(error => {
console.error('Error al cargar catálogo:', error.message);
mostrarMensajeError();
});
Promise.all, Promise.race y Promise.allSettled
Cuando tienes varias operaciones asíncronas independientes, no tiene sentido ejecutarlas una tras otra. JavaScript te ofrece métodos estáticos en la clase Promise para gestionar múltiples Promesas a la vez:
Promise.all(): recibe un array de Promesas y devuelve una nueva Promesa que se resuelve cuando todas se han resuelto, con un array de todos los resultados en el mismo orden. Si cualquiera se rechaza, la Promesa resultante se rechaza inmediatamente con ese error.
// Cargar usuario, pedidos y preferencias al mismo tiempo (en paralelo)
const [usuario, pedidos, preferencias] = await Promise.all([
fetch('/api/usuario/1').then(r => r.json()),
fetch('/api/usuario/1/pedidos').then(r => r.json()),
fetch('/api/usuario/1/preferencias').then(r => r.json())
]);
// Las tres peticiones se lanzan simultáneamente
// Solo continuamos cuando las tres han terminado
console.log(usuario.nombre, pedidos.length, preferencias.tema);
Promise.race(): devuelve una Promesa que se resuelve o rechaza tan pronto como la primera Promesa del array lo haga. Útil para implementar timeouts: lanzas tu operación real y una Promesa que se rechaza tras X segundos, y la que gane determina el resultado.
// Timeout con Promise.race
function conTimeout(promesa, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Tiempo de espera agotado')), ms)
);
return Promise.race([promesa, timeout]);
}
// Si la petición tarda más de 5 segundos, falla con timeout
const datos = await conTimeout(fetch('/api/datos-lentos'), 5000);
Promise.allSettled(): similar a Promise.all pero no falla si alguna se rechaza. Espera a que todas terminen (resuelta o rechazada) y devuelve un array con el estado y valor/razón de cada una. Útil cuando quieres manejar errores individuales sin que un fallo cancele todo.
const resultados = await Promise.allSettled([
fetch('/api/servicio-a').then(r => r.json()),
fetch('/api/servicio-b').then(r => r.json()),
fetch('/api/servicio-c').then(r => r.json())
]);
resultados.forEach((resultado, i) => {
if (resultado.status === 'fulfilled') {
console.log('Servicio', i + 1, 'OK:', resultado.value);
} else {
console.error('Servicio', i + 1, 'falló:', resultado.reason.message);
}
});
Async/await: la sintaxis moderna para trabajar con Promesas
Async/await es una sintaxis introducida en ES2017 que hace que trabajar con Promesas parezca código síncrono. No es una alternativa a las Promesas, sino una forma diferente de consumirlas. Por debajo, async/await usa Promesas exactamente igual.
La palabra clave async antes de una función la convierte en una función asíncrona que siempre devuelve una Promesa. Dentro de esa función, puedes usar await antes de cualquier Promesa para pausar la ejecución de esa función hasta que la Promesa se resuelva.
// Con .then() encadenado
function cargarPerfil(id) {
return fetch('/api/usuario/' + id)
.then(r => r.json())
.then(usuario => {
return fetch('/api/usuario/' + usuario.id + '/avatar')
.then(r => r.blob())
.then(blob => ({ ...usuario, avatarUrl: URL.createObjectURL(blob) }));
});
}
// Con async/await — mismo comportamiento, mucho más legible
async function cargarPerfil(id) {
const respuesta = await fetch('/api/usuario/' + id);
const usuario = await respuesta.json();
const avatarRespuesta = await fetch('/api/usuario/' + usuario.id + '/avatar');
const blob = await avatarRespuesta.blob();
return { ...usuario, avatarUrl: URL.createObjectURL(blob) };
}
Para manejar errores con async/await usas try/catch, que funciona exactamente como con código síncrono. Cualquier Promesa rechazada dentro del bloque try lanzará una excepción que capturará el bloque catch.
async function cargarDatos(url) {
try {
const respuesta = await fetch(url);
if (!respuesta.ok) {
throw new Error('Error HTTP: ' + respuesta.status);
}
const datos = await respuesta.json();
return datos;
} catch (error) {
if (error.name === 'TypeError') {
throw new Error('Error de red: sin conexión o URL incorrecta');
}
throw error; // relanzamos otros errores
} finally {
ocultarSpinner(); // siempre se ejecuta
}
}
Errores comunes que todos cometen con Promesas
No devolver la Promesa en un .then(): si llamas a una función asíncrona dentro de un .then() pero no la devuelves, la cadena no esperará a que termine. Este es uno de los bugs más difíciles de detectar porque el código parece correcto.
// MAL: no se devuelve la promesa interna
fetch('/api/paso1')
.then(r => r.json())
.then(datos => {
fetch('/api/paso2/' + datos.id); // ¡falta el return!
// La cadena continúa sin esperar a paso2
})
.then(() => console.log('paso2 terminado')); // esto se ejecuta antes de que paso2 termine
// BIEN: devolviendo la promesa
fetch('/api/paso1')
.then(r => r.json())
.then(datos => {
return fetch('/api/paso2/' + datos.id); // ahora sí esperamos
})
.then(() => console.log('paso2 terminado'));
Usar await fuera de una función async: await solo funciona dentro de funciones marcadas con async. Usarlo en el nivel superior de un módulo es posible en JavaScript moderno (top-level await), pero en entornos más antiguos causará un error de sintaxis.
No capturar errores: una Promesa rechazada sin .catch() o sin un try/catch alrededor del await genera un «Unhandled Promise Rejection». En versiones antiguas de Node.js esto podía pasar silenciosamente; en versiones modernas termina el proceso con un error.
Usar await en bucles cuando podría ser paralelo: si usas await dentro de un bucle for, las operaciones se ejecutan de forma secuencial, una tras otra. Si son independientes, usa Promise.all() para ejecutarlas en paralelo y reducir el tiempo total de espera.
const ids = [1, 2, 3, 4, 5];
// LENTO: espera cada petición antes de lanzar la siguiente
for (const id of ids) {
const usuario = await fetch('/api/usuario/' + id).then(r => r.json());
procesarUsuario(usuario);
}
// RÁPIDO: lanza todas las peticiones a la vez
const promesas = ids.map(id => fetch('/api/usuario/' + id).then(r => r.json()));
const usuarios = await Promise.all(promesas);
usuarios.forEach(procesarUsuario);
Las Promesas y async/await son la base de prácticamente todo el código JavaScript moderno que interactúa con APIs, bases de datos o cualquier operación que tome tiempo. Entenderlos bien no es opcional si quieres escribir código JavaScript de calidad profesional. Con esta guía tienes la base necesaria para leer, escribir y depurar código asíncrono con confianza.
Si quieres profundizar más, el siguiente paso natural es aprender sobre la Fetch API, que usa Promesas para todas sus operaciones, y explorar los patrones avanzados de manejo de errores en aplicaciones grandes. También te recomiendo revisar nuestra guía sobre TypeScript, que añade tipado estático a las Promesas y hace el código asíncrono aún más robusto.