Promesas en JavaScript: qué son y cómo evitar el callback hell

D
DanisCh
(Actualizado: ) 12 min de lectura
Promesas en JavaScript: qué son y cómo evitar el callback hell
Consejos para estudiar programación JavaScript

Una de las características más importantes de JavaScript es que es asíncrono: puede iniciar una operación lenta (una petición de red, leer un archivo, esperar un temporizador) y continuar ejecutando otro código mientras espera el resultado. Esto es muy eficiente, pero durante años la forma de manejar esas operaciones asíncronas en JavaScript eran los callbacks, que a medida que el código crecía producían un problema conocido como callback hell. Las Promesas llegaron para resolverlo.

En esta guía aprenderás qué son los callbacks, por qué crean problemas, qué son las Promesas y cómo async/await hace que el código asíncrono sea tan legible como el síncrono.

El problema: código síncrono vs asíncrono

En el código síncrono, las instrucciones se ejecutan una detrás de otra y cada una espera a que la anterior termine:

// Código síncrono: predecible y fácil de seguir
const nombre = "Ana";
const saludo = `Hola, ${nombre}`;
console.log(saludo);   // "Hola, Ana"
// Cada línea se ejecuta antes de pasar a la siguiente

Pero cuando hay una operación que tarda tiempo (una petición HTTP, leer un archivo, consultar una base de datos), no puedes bloquear el hilo de ejecución esperando. JavaScript es de un solo hilo: si se bloquea esperando una respuesta, la página entera se congela.

// ¿Qué pasaría si JavaScript fuera bloqueante?
const datos = fetch('https://api.ejemplo.com/usuarios');  // imagina que tarda 2 segundos
// ← durante esos 2 segundos, el navegador estaría completamente bloqueado
// no podría responder a clics, no podría animar nada, estaría congelado

console.log(datos);   // en realidad esto no funciona así

La solución de JavaScript es el modelo asíncrono: inicias la operación y le dices "cuando termines, ejecuta esta función con el resultado". Esa función que se pasa como argumento para ejecutarse después es un callback.

Los callbacks: la solución original

// Un callback es una función que se pasa como argumento
// para que se ejecute cuando una operación asíncrona termina

setTimeout(function() {
  console.log("Han pasado 2 segundos");
}, 2000);

console.log("Esto se ejecuta ANTES, aunque esté después en el código");

// Salida:
// "Esto se ejecuta ANTES, aunque esté después en el código"
// (2 segundos después) "Han pasado 2 segundos"
// Ejemplo con lectura de archivo en Node.js
const fs = require('fs');

fs.readFile('datos.txt', 'utf8', function(error, contenido) {
  if (error) {
    console.error('Error al leer el archivo:', error);
    return;
  }
  console.log(contenido);
});

console.log("Esta línea se ejecuta antes de que readFile termine");

Para tareas simples, los callbacks funcionan bien. El problema aparece cuando necesitas encadenar varias operaciones asíncronas, donde cada una depende del resultado de la anterior.

El callback hell

// Escenario real: obtener un usuario, luego sus pedidos, luego los detalles de cada pedido
obtenerUsuario(usuarioId, function(error, usuario) {
 if (error) {
   manejarError(error);
   return;
 }
 obtenerPedidos(usuario.id, function(error, pedidos) {
   if (error) {
     manejarError(error);
     return;
   }
   obtenerDetalles(pedidos[0].id, function(error, detalles) {
     if (error) {
       manejarError(error);
       return;
     }
     calcularTotal(detalles, function(error, total) {
       if (error) {
         manejarError(error);
         return;
       }
       guardarResultado(total, function(error, resultado) {
         if (error) {
           manejarError(error);
           return;
         }
         console.log('Proceso completado:', resultado);
         // y aquí podría haber más niveles...
       });
     });
   });
 });
});
// La forma del código → una pirámide hacia la derecha
// Apodado "callback hell" o "pyramid of doom" (pirámide de la perdición)

El callback hell tiene tres problemas graves: es difícil de leer (el flujo visual de izquierda a derecha no corresponde al flujo lógico de arriba a abajo), el manejo de errores se repite en cada nivel, y es casi imposible de depurar porque el stack de llamadas no refleja el orden lógico del código.

Las Promesas: la solución

Una Promesa (Promise) es un objeto que representa el resultado eventual de una operación asíncrona. En el momento en que se crea, la operación todavía no ha terminado: la Promesa es una "promesa" de que habrá un resultado en el futuro.

Una Promesa puede estar en uno de tres estados:

  • Pending (pendiente): la operación todavía no ha terminado.
  • Fulfilled (resuelta): la operación terminó con éxito y tiene un valor.
  • Rejected (rechazada): la operación falló y tiene un motivo de error.

Una vez que una Promesa pasa de pending a fulfilled o rejected, su estado no puede cambiar.

// Crear una Promesa manualmente
const miPromesa = new Promise(function(resolve, reject) {
  // Aquí va la operación asíncrona
  const exito = true;

  if (exito) {
    resolve("¡Operación completada!");   // → fulfilled
  } else {
    reject(new Error("Algo salió mal")); // → rejected
  }
});

// Consumir la Promesa con .then() y .catch()
miPromesa
  .then(function(resultado) {
    console.log(resultado);   // "¡Operación completada!"
  })
  .catch(function(error) {
    console.error(error.message);
  });
// Promesa con una operación asíncrona real
function esperar(ms) {
  return new Promise(function(resolve) {
    setTimeout(resolve, ms);
  });
}

esperar(1000)
  .then(function() {
    console.log("Ha pasado 1 segundo");
    return esperar(1000);   // devolver otra Promesa encadena el .then()
  })
  .then(function() {
    console.log("Han pasado 2 segundos");
  });

Encadenar Promesas: la solución al callback hell

El poder de las Promesas es que se pueden encadenar: cuando un .then() devuelve una Promesa, el siguiente .then() espera a que esa Promesa se resuelva. Esto transforma la pirámide de callbacks en una cadena horizontal.

// El mismo ejemplo del callback hell, con Promesas:

obtenerUsuario(usuarioId)
  .then(usuario => obtenerPedidos(usuario.id))
  .then(pedidos => obtenerDetalles(pedidos[0].id))
  .then(detalles => calcularTotal(detalles))
  .then(total => guardarResultado(total))
  .then(resultado => console.log('Proceso completado:', resultado))
  .catch(error => manejarError(error));   // un solo catch para todos los errores

// Mucho más legible:
// - Flujo visual de arriba a abajo
// - Un solo punto de manejo de errores
// - Cada paso tiene una responsabilidad clara

Manejo de errores con .catch()

// .catch() captura cualquier error en cualquier punto de la cadena
fetch('https://api.ejemplo.com/usuarios/1')
  .then(respuesta => {
    if (!respuesta.ok) {
      throw new Error(`HTTP error: ${respuesta.status}`);
    }
    return respuesta.json();
  })
  .then(usuario => {
    console.log(usuario.nombre);
  })
  .catch(error => {
    // Captura errores de red, errores HTTP lanzados con throw,
    // y errores de parseo de JSON
    console.error('Error en la cadena:', error.message);
  })
  .finally(() => {
    // Se ejecuta siempre, haya error o no
    // Útil para ocultar un spinner de carga, cerrar conexiones, etc.
    ocultarSpinner();
  });

Ejecutar Promesas en paralelo

// Promise.all: esperar a que TODAS terminen (falla si alguna falla)
const [usuarios, productos, categorias] = await Promise.all([
  fetch('/api/usuarios').then(r => r.json()),
  fetch('/api/productos').then(r => r.json()),
  fetch('/api/categorias').then(r => r.json())
]);
// Las tres peticiones se hacen EN PARALELO, no una tras otra
// Si hicieras await de cada una por separado, tardaría 3x más

// Promise.allSettled: esperar a que TODAS terminen, sin importar si fallan
const resultados = await Promise.allSettled([
  fetch('/api/usuarios').then(r => r.json()),
  fetch('/api/datos-opcionales').then(r => r.json()),   // puede fallar
]);

resultados.forEach(resultado => {
  if (resultado.status === 'fulfilled') {
    console.log('Éxito:', resultado.value);
  } else {
    console.log('Error:', resultado.reason);
  }
});
// Útil cuando algunas peticiones son opcionales y un fallo no debe detener todo

// Promise.race: la primera que termine gana
const respuesta = await Promise.race([
  fetch('/api/datos'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), 5000)
  )
]);
// Si la petición tarda más de 5 segundos, el timeout rechaza la promesa

async/await: el nivel final de legibilidad

async/await es sintaxis especial de JavaScript (introducida en ES2017) que hace que el código asíncrono se lea como código síncrono. Por debajo sigue usando Promesas: async/await es azúcar sintáctico sobre ellas.

Una función marcada con async siempre devuelve una Promesa. Dentro de una función async, puedes usar await delante de una Promesa para pausar la ejecución de esa función hasta que la Promesa se resuelva, sin bloquear el hilo principal.

// Con .then() encadenado
function cargarPerfil(usuarioId) {
  return obtenerUsuario(usuarioId)
    .then(usuario => obtenerPedidos(usuario.id)
      .then(pedidos => ({ usuario, pedidos }))
    )
    .then(({ usuario, pedidos }) => {
      console.log(usuario.nombre, pedidos.length);
    })
    .catch(error => console.error(error));
}

// Con async/await: mismo resultado, mucho más legible
async function cargarPerfil(usuarioId) {
  try {
    const usuario = await obtenerUsuario(usuarioId);
    const pedidos = await obtenerPedidos(usuario.id);
    console.log(usuario.nombre, pedidos.length);
  } catch (error) {
    console.error(error);
  }
}
// Ejemplo completo y real: API de usuarios con fetch

async function obtenerPerfilCompleto(usuarioId) {
  try {
    // Petición 1: datos del usuario
    const respUsuario = await fetch(`/api/usuarios/${usuarioId}`);
    if (!respUsuario.ok) throw new Error(`Usuario no encontrado: ${respUsuario.status}`);
    const usuario = await respUsuario.json();

    // Petición 2: pedidos del usuario (depende del resultado anterior)
    const respPedidos = await fetch(`/api/pedidos?usuarioId=${usuario.id}`);
    const pedidos = await respPedidos.json();

    // Petición 3: perfil adicional (en paralelo con los pedidos sería más eficiente,
    // pero aquí lo mostramos secuencial para la claridad)
    const respPerfil = await fetch(`/api/perfiles/${usuario.id}`);
    const perfil = await respPerfil.json();

    return { usuario, pedidos, perfil };

  } catch (error) {
    console.error('Error cargando el perfil:', error.message);
    throw error;   // relanzar para que el llamante pueda manejarlo también
  }
}

// Llamar a la función async
obtenerPerfilCompleto(123)
  .then(datos => mostrarPerfil(datos))
  .catch(error => mostrarError(error));

// O dentro de otra función async:
async function iniciarPagina() {
  const datos = await obtenerPerfilCompleto(123);
  mostrarPerfil(datos);
}

Paralelismo con async/await

// ❌ Secuencial cuando podría ser paralelo (lento)
async function cargarDatos() {
  const usuarios   = await fetch('/api/usuarios').then(r => r.json());
  const productos  = await fetch('/api/productos').then(r => r.json());
  const categorias = await fetch('/api/categorias').then(r => r.json());
  // Esto hace las peticiones UNA TRAS OTRA
  // Si cada una tarda 300ms, el total es ~900ms
}

// ✅ En paralelo con Promise.all (rápido)
async function cargarDatos() {
  const [usuarios, productos, categorias] = await Promise.all([
    fetch('/api/usuarios').then(r => r.json()),
    fetch('/api/productos').then(r => r.json()),
    fetch('/api/categorias').then(r => r.json()),
  ]);
  // Las tres peticiones se hacen A LA VEZ
  // El tiempo total es ~300ms (el de la más lenta), no ~900ms
}

Errores comunes con async/await

// ❌ Error 1: olvidar el await
async function obtenerNombre(id) {
  const usuario = fetch(`/api/usuarios/${id}`);   // falta await
  console.log(usuario.nombre);   // undefined: usuario es una Promesa, no el objeto
}

// ✅ Correcto
async function obtenerNombre(id) {
  const respuesta = await fetch(`/api/usuarios/${id}`);
  const usuario   = await respuesta.json();
  console.log(usuario.nombre);
}


// ❌ Error 2: usar await fuera de una función async
// await fetch('/api/datos');  → SyntaxError en módulos CommonJS
// (en módulos ES top-level await está permitido desde Node.js 14.8)

// ✅ Envolver en función async
async function cargar() {
  const datos = await fetch('/api/datos');
}
cargar();


// ❌ Error 3: no manejar errores en funciones async no esperadas
async function tarea() {
  throw new Error("algo salió mal");
}

tarea();   // la Promesa se rechaza pero nadie la captura → UnhandledPromiseRejection

// ✅ Siempre maneja el error o espera la Promesa
tarea().catch(e => console.error(e));
// o
async function ejecutar() {
  try {
    await tarea();
  } catch(e) {
    console.error(e);
  }
}

Cuándo usar .then() y cuándo async/await

En la mayoría de casos, async/await es más legible y es la preferencia del ecosistema moderno. Pero hay situaciones donde .then() sigue siendo más conveniente:

// .then() es más conveniente para transformaciones simples en cadena
fetch('/api/usuarios')
  .then(r => r.json())
  .then(usuarios => usuarios.filter(u => u.activo))
  .then(activos => activos.map(u => u.nombre))
  .then(nombres => console.log(nombres));

// async/await es más conveniente cuando hay lógica condicional
async function procesarPedido(id) {
  const pedido = await obtenerPedido(id);

  if (pedido.estado === 'cancelado') {
    return { mensaje: 'El pedido ya está cancelado' };
  }

  const stock = await verificarStock(pedido.productos);
  if (!stock.disponible) {
    await notificarAgotado(pedido);
    return { mensaje: 'Producto agotado' };
  }

  const pago = await procesarPago(pedido.total);
  await actualizarEstado(pedido.id, 'confirmado');
  await enviarConfirmacion(pedido.email);

  return { mensaje: 'Pedido confirmado', referencia: pago.id };
}
// Con .then() encadenado, las condiciones condicionales serían mucho más complicadas

Resumen

  • JavaScript es asíncrono: puede iniciar operaciones lentas y continuar ejecutando otro código mientras espera. Los callbacks fueron la primera forma de manejar los resultados de esas operaciones.
  • El callback hell aparece cuando se encadenan varios callbacks dependientes: el código forma una pirámide hacia la derecha, el manejo de errores se repite y el flujo es difícil de seguir.
  • Una Promesa es un objeto que representa el resultado futuro de una operación asíncrona. Puede estar pendiente, resuelta o rechazada. Una vez resuelta o rechazada, no cambia.
  • .then() encadena operaciones asíncronas de forma horizontal, eliminando la pirámide. Un solo .catch() al final maneja todos los errores de la cadena.
  • Promise.all ejecuta múltiples Promesas en paralelo y espera a que todas terminen. Promise.allSettled hace lo mismo pero sin fallar si alguna se rechaza.
  • async/await es azúcar sintáctico sobre Promesas que hace que el código asíncrono se lea como síncrono. Usa try/catch para el manejo de errores.
  • El error más habitual con async/await es olvidar el await, lo que hace que la variable reciba una Promesa en lugar del valor resuelto.
  • Para peticiones independientes, usa siempre Promise.all en lugar de await secuencial: es hasta 3 veces más rápido para tres peticiones paralelas.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a