Todos los artículos
javascript async promesas fetch

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.

beresiartejuan

@beresiartejuan

Autor

20 de febrero de 2026 12 min de lectura

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 curso
  • fulfilled — terminó con éxito, hay un valor disponible
  • rejected — 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/await para la mayoría del código asíncrono
  • Promise.all cada vez que tenemos operaciones independientes
  • Promise.allSettled cuando queremos tolerancia a fallos parciales
  • try/catch para manejar errores, con re-lanzamiento cuando corresponde
  • new Promise solo 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.