Asincronía en JavaScript: callbacks, promesas y async/await

La asincronía en JavaScript es lo que permite que una web siga respondiendo mientras espera una tarea lenta. Por ejemplo, una petición a un servidor, un temporizador o la lectura de un archivo. En lugar de congelar la página, JavaScript sigue con lo suyo y ejecuta el resultado cuando llega.

En esta guía verás las tres formas de manejarla: callbacks, promesas y async/await. También cómo funciona el event loop por dentro y los errores que más se repiten. He probado todo el código en Node 22 y en el navegador, y en los comentarios tienes la salida real.

Respuesta rápida: usa async/await con try...catch para casi todo, y Promise.all cuando tengas varias tareas independientes. Los callbacks siguen vivos en los eventos, pero para encadenar tareas asíncronas ya no se usan.

CallbacksPromesasAsync/await
Cómo se escribeUna función como argumento.then() y .catch()async + await
Con varias tareas seguidasAnidación difícil de leerCadena legibleParece código secuencial
ErroresA mano, en cada callback.catch()try...catch
Disponible desdeSiempreES2015ES2017
Cuándo usarlo hoyEventos y APIs antiguasCombinar tareas (Promise.all)Por defecto
Callbacks, promesas y async/await comparados.

Este artículo forma parte de la hoja de ruta para aprender JavaScript. Si aún estás con lo básico, empieza por ahí.

Qué es la asincronía en JavaScript y por qué importa

JavaScript ejecuta el código de arriba abajo, una instrucción tras otra. Pero algunas tareas no terminan al instante (pedir datos a una API puede tardar segundos). Si JavaScript se quedara esperando, la página se congelaría. La asincronía resuelve esto: lanza la tarea, sigue con el resto del código y ejecuta una función cuando el resultado está listo.

Los casos típicos son pedir datos con fetch, esperar un temporizador, responder a un clic o leer un archivo en Node.

Cómo funciona por dentro: el event loop

JavaScript tiene un solo hilo, así que solo ejecuta una cosa a la vez. El truco está en que delega las tareas lentas en el navegador (o en Node) y recoge el resultado más tarde. Quien coordina todo es el event loop.

Mira este ejemplo y piensa en qué orden salen los mensajes:

console.log('1. Inicio');
setTimeout(() => console.log('2. setTimeout'), 0);
Promise.resolve().then(() => console.log('3. Promesa'));
console.log('4. Fin');

// Salida: 1. Inicio, 4. Fin, 3. Promesa, 2. setTimeout

Primero va el código síncrono. Después se vacía la cola de microtareas, donde esperan las promesas. Solo entonces el event loop toma el setTimeout de la cola de tareas, aunque su espera sea de 0 ms.

Diagrama del event loop en JavaScript: pila de llamadas, APIs del navegador, cola de microtareas y cola de tareas
Diagrama del event loop en JavaScript: pila de llamadas, APIs del navegador, cola de microtareas y cola de tareas

Por eso un setTimeout de 0 ms no se ejecuta «ya». Se ejecuta en cuanto la pila y las microtareas quedan libres.

Callbacks

Un callback es una función que pasas a otra para que la ejecute cuando termine. Es la forma más antigua.

function pedirDatos(callback) {
  setTimeout(() => {
    callback('Datos recibidos');
  }, 1000);
}

pedirDatos((resultado) => {
  console.log(resultado); // "Datos recibidos" tras 1 segundo
});

En Node verás mucho el patrón error primero: el callback recibe el error como primer argumento y el resultado como segundo. Si no hay error, el primero llega como null.

function leerArchivo(nombre, callback) {
  setTimeout(() => {
    if (!nombre) {
      callback(new Error('Falta el nombre del archivo'));
      return;
    }
    callback(null, `Contenido de ${nombre}`);
  }, 500);
}

leerArchivo('notas.txt', (error, contenido) => {
  if (error) {
    console.error(error.message);
    return;
  }
  console.log(contenido); // "Contenido de notas.txt"
});

El problema aparece al encadenar varias tareas: los callbacks se anidan unos dentro de otros y el código se vuelve ilegible. Es el famoso «callback hell». En esquema, se ve así:

pedirUsuario(1, (usuario) => {
  pedirPedidos(usuario.id, (pedidos) => {
    pedirDetalle(pedidos[0], (detalle) => {
      console.log(detalle);
      // y cada nivel necesita su propio control de errores
    });
  });
});

Promesas

Una promesa (Promise) representa un valor que llegará en el futuro. Puede resolverse (éxito) o rechazarse (error), y se encadena con .then() y .catch(), mucho más legible.

function pedirDatos() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Datos recibidos');
      // si algo falla: reject('Error al pedir datos');
    }, 1000);
  });
}

pedirDatos()
  .then((resultado) => console.log(resultado))
  .catch((error) => console.error(error));

Una promesa siempre está en uno de estos tres estados, y cuando sale de «pendiente» ya no cambia:

  • Pendiente (pending): la tarea sigue en marcha.
  • Cumplida (fulfilled): terminó bien y tiene un valor.
  • Rechazada (rejected): falló y tiene un motivo de error.

Lo potente es que puedes encadenar varios .then() sin anidar, cada uno recibiendo el resultado del anterior. Y .finally() se ejecuta tanto si sale bien como si falla:

pedirDatos()
  .then((texto) => texto.toUpperCase())
  .then((mayusculas) => console.log(mayusculas)) // "DATOS RECIBIDOS"
  .catch((error) => console.error(error))
  .finally(() => console.log('Petición terminada'));

Async/await

Async/await es azúcar sintáctico sobre las promesas: te deja escribir código asíncrono como si fuera secuencial, mucho más fácil de leer. Marcas la función con async y usas await para esperar una promesa.

async function mostrarDatos() {
  const resultado = await pedirDatos();
  console.log(resultado);
}

mostrarDatos();

El await pausa la función hasta que la promesa se resuelve, sin bloquear el resto de la página. Es la forma recomendada hoy en día.

Un ejemplo real: pedir datos a una API

Con un temporizador se entiende la idea, pero el uso real es pedir datos a un servidor. Este ejemplo usa JSONPlaceholder, una API de pruebas gratuita:

async function cargarUsuario(id) {
  try {
    const respuesta = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
    if (!respuesta.ok) {
      throw new Error(`Error HTTP ${respuesta.status}`);
    }
    const usuario = await respuesta.json();
    console.log(usuario.name);
  } catch (error) {
    console.error('No se pudo cargar el usuario:', error.message);
  }
}

cargarUsuario(1);   // "Leanne Graham"
cargarUsuario(999); // "No se pudo cargar el usuario: Error HTTP 404"

Fíjate en la comprobación de respuesta.ok. fetch solo rechaza la promesa si falla la red: un 404 o un 500 llegan como respuesta válida. Lo he comprobado con el id 999: sin esa línea, el código sigue como si nada con un objeto vacío.

Manejo de errores

Con promesas usas .catch(). Con async/await, envuelves el await en un bloque try...catch:

async function mostrarDatos() {
  try {
    const resultado = await pedirDatos();
    console.log(resultado);
  } catch (error) {
    console.error('Algo falló:', error);
  }
}

Nunca dejes una promesa sin gestionar el error: si falla y no la capturas, tendrás un fallo silencioso difícil de depurar.

Varias tareas a la vez: Promise.all y sus variantes

Si necesitas lanzar varias tareas en paralelo y esperar a que todas terminen, usa Promise.all. Es más rápido que esperarlas una a una.

async function cargarTodo() {
  const [usuarios, productos] = await Promise.all([
    pedirDatos(),
    pedirDatos()
  ]);
  console.log(usuarios, productos);
}

Secuencial o en paralelo: la diferencia en tiempo

Para que veas cuánto cambia, aquí tienes dos tareas de un segundo cada una, primero en serie y luego en paralelo:

const esperar = (ms, valor) =>
  new Promise((resolve) => setTimeout(() => resolve(valor), ms));

async function secuencial() {
  console.time('secuencial');
  const a = await esperar(1000, 'A');
  const b = await esperar(1000, 'B');
  console.timeEnd('secuencial'); // unos 2000 ms
}

async function paralelo() {
  console.time('paralelo');
  const [a, b] = await Promise.all([esperar(1000, 'A'), esperar(1000, 'B')]);
  console.timeEnd('paralelo'); // unos 1000 ms
}

En mi prueba con Node 22, la versión secuencial tardó 2,003 s y la paralela 1,001 s. Con tres o cuatro peticiones a una API, esa diferencia se nota en la carga de la página.

Promise.all, allSettled, race y any

Promise.all tiene tres hermanos. Elige según qué quieras que pase cuando una tarea falla:

MétodoEspera a…Si una tarea falla…Úsalo para
Promise.allTodasRechaza con el primer errorDatos que necesitas completos
Promise.allSettledTodasNunca rechaza: te da el estado de cada unaMostrar lo que sí llegó
Promise.raceLa primera que termineRechaza si la primera en terminar fallaPoner un tiempo límite
Promise.anyLa primera que salga bienSolo rechaza si fallan todas (AggregateError)Servidores de respaldo
Qué método de Promise usar según el caso.

Tienes un ejemplo práctico de Promise.allSettled con varias peticiones en mis trucos de JavaScript modernos.

El caso de uso más habitual de la asincronía es consumir APIs. Lo tienes explicado en la guía de Fetch API con JavaScript.

5 errores comunes con async/await

Estos son los fallos que más veo cuando alguien empieza con la asincronía en JavaScript. Todos se arreglan en una línea.

1. Olvidar el await

Si llamas a una función async sin await, no recibes el dato: recibes la promesa.

const datos = pedirDatos();
console.log(datos); // Promise { <pending> }

2. Usar await dentro de forEach

forEach no espera a las funciones async. El código de después se ejecuta antes de que terminen:

const ids = [1, 2, 3];

ids.forEach(async (id) => {
  await esperar(500);
  console.log('Usuario', id);
});
console.log('Terminado'); // sale ANTES que los usuarios

Si necesitas el orden, usa for...of. Si no importa el orden y quieres velocidad, usa Promise.all con map:

// Dentro de una función async:

// En serie, una detrás de otra
for (const id of ids) {
  await esperar(500);
  console.log('Usuario', id);
}

// En paralelo, todas a la vez
await Promise.all(ids.map(async (id) => {
  await esperar(500);
  console.log('Usuario', id);
}));

3. Esperar en serie lo que podría ir en paralelo

Dos await seguidos sobre tareas independientes suman sus tiempos. Si una no necesita el resultado de la otra, lánzalas juntas con Promise.all, como en la prueba de tiempos.

4. No comprobar respuesta.ok

Un 404 no salta al catch por sí solo. Comprueba respuesta.ok y lanza tú el error, como en el ejemplo de la API.

5. Usar await fuera de una función async

En un script normal, await fuera de una función async da error de sintaxis. Solo funciona suelto en el nivel superior de un módulo (<script type="module">) y en la consola del navegador.

Para seguir practicando

La asincronía se aprende escribiendo código, no leyendo. Te propongo este orden:

  1. Monta la app del tiempo de mis 10 proyectos de JavaScript para practicar. Es fetch con async/await contra una API real.
  2. Profundiza en peticiones GET y POST con la guía de Fetch API.
  3. Si te interesa el backend, prueba la asincronía en el servidor con Node.js y npm.
  4. Cuando lo domines, salta a los patrones de diseño y código limpio en JavaScript.

Si prefieres un libro: «Eloquent JavaScript», de Marijn Haverbeke, dedica un capítulo entero a la programación asíncrona, con ejercicios. Tiene edición en papel en español, «JavaScript elocuente» de Anaya, y una traducción online gratis por si quieres probarlo antes.

¿Vas a montar tu primera app con una API?

Los datos los traes con JavaScript, pero la interfaz hay que maquetarla. Mi pack CSS gratis trae más de 40 estilos listos para copiar, botones incluidos. Apúntate y te lo envío al correo.

Preguntas frecuentes

¿JavaScript es síncrono o asíncrono?

El lenguaje es síncrono y de un solo hilo: ejecuta una instrucción tras otra. La asincronía la pone el entorno, el navegador o Node. Hace el trabajo lento aparte y devuelve el resultado a través del event loop.

¿Qué diferencia hay entre callbacks, promesas y async/await?

Son tres generaciones de lo mismo. Los callbacks son lo más antiguo y se vuelven ilegibles al anidarse. Las promesas los sustituyen con encadenamiento limpio. Async/await es la capa moderna sobre promesas, la más legible. Hoy se usa async/await.

¿Async/await bloquea la página?

No. await pausa solo la función donde está, no el navegador. El resto de la página sigue respondiendo con normalidad.

¿Cuándo uso Promise.all?

Cuando tienes varias tareas independientes que pueden ejecutarse a la vez y quieres esperar a que todas terminen. Si una depende del resultado de otra, encadénalas con await, una tras otra.

¿Puedo usar await fuera de una función async?

Solo en el nivel superior de un módulo ES o en la consola del navegador. En un script normal tienes que meterlo dentro de una función async.

¿Qué significa Promise { <pending> } en la consola?

Que has mostrado la promesa antes de que termine. Te falta un await delante o leer el valor dentro de un .then().

Con la asincronía en JavaScript dominada, el siguiente paso natural es consumir APIs con Fetch. Y para seguir el temario completo, vuelve a la hoja de ruta de JavaScript.

Si esta guía te ha aclarado las promesas, compártela con quien esté peleándose con ellas.

Como Afiliada de Amazon, obtengo ingresos por las compras adscritas que cumplen los requisitos. Esto no supone ningún coste adicional para ti.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio