Saltar al contenido
asincronia.js · devschool

Asincronía en JavaScript

Lección 32 de 38 · 10 min de lectura · Actualizado el

En esta lección
  1. Síncrono frente a asíncrono
  2. Un solo hilo y el event loop
  3. Callbacks y el callback hell
  4. Promesas
  5. Varias promesas a la vez
  6. async y await
  7. Errores frecuentes
  8. Resumen

Algunas operaciones tardan: pedir datos a un servidor, esperar a que el usuario pulse un botón, leer un archivo, esperar dos segundos. Si JavaScript se quedara parado esperando, la página se congelaría. Por eso existe la asincronía: lanzar una tarea, seguir con otras cosas y reaccionar cuando termine. En esta lección vas a entender cómo lo consigue JavaScript con un solo hilo (el event loop), y las tres formas de escribir código asíncrono: callbacks, promesas y async/await.

Para pedir datos a un servidor en la práctica, después de esta lección tienes fetch, promesas y async/await. Los temporizadores los viste en setTimeout y setInterval.

Síncrono frente a asíncrono

El código síncrono se ejecuta línea a línea, y cada una espera a que termine la anterior:

console.log("A");
console.log("B");
console.log("C");
// A, B, C

Una operación asíncrona se lanza ahora y termina más tarde. Mientras tanto, el programa sigue:

console.log("A");
setTimeout(() => console.log("B"), 1000);
console.log("C");
// A, C y, un segundo después, B

Piensa en una cafetería. Un camarero síncrono tomaría tu pedido, se quedaría mirando la cafetera hasta que el café esté hecho y solo entonces atendería al siguiente cliente. Un camarero asíncrono toma tu pedido, lo pasa a la barra y atiende al siguiente; cuando el café está listo, te avisan. JavaScript es el segundo camarero.

Un solo hilo y el event loop

JavaScript ejecuta tu código en un solo hilo: solo puede hacer una cosa a la vez. Entonces, ¿cómo espera a un servidor sin bloquearse? Porque no es él quien espera. El navegador (o Node) se encarga de los temporizadores, las peticiones de red y los eventos, y cuando uno termina, deja su callback en una cola. El event loop (bucle de eventos) es el mecanismo que, cada vez que tu código termina, mira las colas y ejecuta lo siguiente.

Las piezas son:

  1. La pila de llamadas (call stack): el código que se está ejecutando ahora.
  2. La cola de microtareas: callbacks de promesas (then, catch, await). Tienen prioridad.
  3. La cola de tareas (macrotareas): callbacks de setTimeout, setInterval, eventos del usuario, etc.

La regla del event loop: cuando la pila se vacía, se ejecutan TODAS las microtareas pendientes; después, UNA tarea; y vuelta a empezar.

El ejemplo que lo explica todo

console.log("1. Inicio");

setTimeout(() => console.log("4. setTimeout con 0 ms"), 0);

Promise.resolve().then(() => console.log("3. then (microtarea)"));

console.log("2. Fin del código síncrono");

Salida:

1. Inicio
2. Fin del código síncrono
3. then (microtarea)
4. setTimeout con 0 ms

Paso a paso:

  1. Se imprime “1”.
  2. setTimeout deja su callback en la cola de tareas (aunque sea con 0 ms, nunca se ejecuta al momento).
  3. then deja su callback en la cola de microtareas.
  4. Se imprime “2”. La pila queda vacía.
  5. El event loop ejecuta todas las microtareas: “3”.
  6. Después, una tarea: “4”.

Consecuencia práctica: un bucle síncrono muy largo bloquea todo. Mientras se ejecuta, no se procesa ningún clic ni ningún temporizador, y la página parece colgada.

Callbacks y el callback hell

La primera forma de trabajar con asincronía fue pasar un callback que se llama al terminar (repasa callbacks si lo necesitas):

function cargarUsuario(id, callback) {
  setTimeout(() => {
    callback({ id, nombre: "Ana" });
  }, 500);
}

cargarUsuario(7, (usuario) => {
  console.log(usuario.nombre);
});

Funciona bien con una sola operación. El problema llega cuando una depende de otra, y esa de otra más:

cargarUsuario(7, (usuario) => {
  cargarPedidos(usuario, (pedidos) => {
    cargarDetalle(pedidos[0], (detalle) => {
      calcularEnvio(detalle, (envio) => {
        console.log(envio);
        // y cada nivel necesita, además, su gestión de errores...
      });
    });
  });
});

Esta pirámide que crece hacia la derecha se llama callback hell (el infierno de los callbacks). Es difícil de leer, de mantener y, sobre todo, de gestionar los errores. Las promesas nacieron para resolverlo.

Promesas

Una promesa es un objeto que representa un resultado que llegará en el futuro. Tiene tres estados:

EstadoSignifica
pending (pendiente)Todavía no ha terminado
fulfilled (cumplida)Terminó bien y tiene un valor
rejected (rechazada)Falló y tiene un motivo (normalmente un Error)

Una vez cumplida o rechazada, ya no cambia.

Crear una promesa: new Promise

Normalmente usarás promesas que te dan otras funciones (fetch, por ejemplo), pero crear una te ayuda a entenderlas. El constructor recibe una función con dos parámetros, resolve y reject:

function pedirPizza(masa) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (masa === "sin gluten") {
        reject(new Error("No queda masa sin gluten"));
      } else {
        resolve(`Pizza con masa ${masa}`);
      }
    }, 500);
  });
}
  • resolve(valor) cumple la promesa con ese valor.
  • reject(error) la rechaza. Pasa siempre un Error, no un texto, para tener la traza.

Un caso muy útil: una función para esperar.

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

Consumir una promesa: then, catch y finally

pedirPizza("fina")
  .then((pizza) => console.log("Llega:", pizza))
  .catch((error) => console.log("Error:", error.message))
  .finally(() => console.log("Pedido cerrado"));
// Llega: Pizza con masa fina
// Pedido cerrado

pedirPizza("sin gluten")
  .then((pizza) => console.log("Llega:", pizza))
  .catch((error) => console.log("Error:", error.message))
  .finally(() => console.log("Pedido cerrado"));
// Error: No queda masa sin gluten
// Pedido cerrado
  • then se ejecuta si se cumple y recibe el valor.
  • catch se ejecuta si se rechaza y recibe el error.
  • finally se ejecuta siempre, útil para quitar un “Cargando…”.

Encadenar promesas

then devuelve una promesa nueva con lo que devuelva su callback. Si devuelves otra promesa, el siguiente then espera a que se cumpla. Así la pirámide se convierte en una lista:

buscarUsuario(7)
  .then((usuario) => buscarPedidos(usuario))
  .then((pedidos) => pedidos.reduce((total, p) => total + p.total, 0))
  .then((total) => console.log("Total gastado:", total))
  .catch((error) => console.log("Algo falló:", error.message));
// Total gastado: 45

Un solo catch al final recoge el error de cualquier paso anterior.

Cuidado: dentro de un then, devuelve la promesa (return o flecha sin llaves). Si escribes .then((u) => { buscarPedidos(u); }), el siguiente then no espera y recibe undefined.

Varias promesas a la vez

Cuando tienes varias operaciones independientes, lánzalas juntas y espera el conjunto. Hay cuatro combinadores:

MétodoSe cumple cuando…Se rechaza cuando…
Promise.alltodas se cumplen (array de valores, en orden)una falla
Promise.allSettledtodas terminan, bien o malnunca
Promise.racela primera que termine se cumplela primera que termine falla
Promise.anyla primera que se cumplatodas fallan (AggregateError)
const [usuario, pedidos] = await Promise.all([
  cargarUsuario(7),
  cargarPedidos(7),
]);

allSettled es útil cuando quieres saber qué ha ido bien y qué mal sin que un fallo lo anule todo:

const resultados = await Promise.allSettled([tarea1, tarea2]);
// [ { status: 'fulfilled', value: 'A' },
//   { status: 'rejected', reason: Error: B falla } ]

Y race sirve, por ejemplo, para poner un tiempo máximo:

const limite = (ms) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error("Tiempo agotado")), ms));

await Promise.race([cargarDatos(), limite(5000)]);

async y await

async/await es una forma de escribir código con promesas que parece síncrono. Por debajo son las mismas promesas, pero se lee de arriba abajo:

async function totalGastado(id) {
  try {
    const usuario = await buscarUsuario(id);
    const pedidos = await buscarPedidos(usuario);
    return pedidos.reduce((total, p) => total + p.total, 0);
  } catch (error) {
    console.log("Algo falló:", error.message);
    return 0;
  }
}
  • await pausa esa función (no el programa entero) hasta que la promesa se resuelve, y te da su valor. Si se rechaza, lanza el error.
  • Por eso los errores se capturan con try...catch, como cualquier otro (lo tienes en errores y excepciones).
  • Una función async siempre devuelve una promesa. Quien la llame tendrá que usar await o then.

Compara con la versión de then: hace exactamente lo mismo. Usa async/await por defecto; es más fácil de leer y depurar.

await en bucles: secuencial o en paralelo

Aquí se pierde mucho rendimiento sin darse cuenta:

const ids = [1, 2, 3];

// Secuencial: espera a cada una antes de lanzar la siguiente (~300 ms)
for (const id of ids) {
  const producto = await cargarProducto(id); // cada una tarda 100 ms
}

// En paralelo: las lanza todas y espera el conjunto (~100 ms)
const productos = await Promise.all(ids.map((id) => cargarProducto(id)));

Usa el bucle secuencial solo cuando cada paso necesita el resultado del anterior o no quieres saturar el servidor. Si son independientes, Promise.all.

Cuidado: forEach no espera a los callbacks async. ids.forEach(async (id) => await ...) lanza todo y sigue sin esperar. Usa for...of (secuencial) o Promise.all con map (paralelo).

Top-level await

En los módulos (<script type="module"> o archivos .mjs en Node) puedes usar await fuera de una función:

// datos.mjs
const respuesta = await fetch("/api/config");
export const config = await respuesta.json();

En un script normal no se puede: tienes que envolverlo en una función async.

Errores frecuentes

  • Olvidar el await: obtienes Promise { <pending> } en lugar del valor.
  • Usar await fuera de una función async (y fuera de un módulo): SyntaxError.
  • No devolver la promesa en un then: la cadena no espera.
  • No capturar los errores: una promesa rechazada sin catch aparece como Uncaught (in promise).
  • await dentro de forEach: no espera.
  • Encadenar await que no dependen entre sí: lento sin necesidad; usa Promise.all.
  • Esperar que setTimeout(fn, 0) se ejecute al momento: siempre va después del código síncrono y de las microtareas.

Resumen

ConceptoIdea clave
AsíncronoSe lanza ahora, termina después; el programa sigue
Event loopPila vacía → todas las microtareas → una tarea
Microtareasthen, catch, await: van antes que setTimeout
CallbackFunción que se llama al terminar; anidados = callback hell
new Promise((resolve, reject) => ...)Crear una promesa
then / catch / finallyValor, error, siempre
Promise.all / allSettled / race / anyCombinar varias
async / awaitPromesas escritas como código secuencial
try...catchCapturar errores con await
Promise.all(lista.map(...))Varias en paralelo

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

asincronia.js
Consola

Pon a prueba lo que has aprendido

[JavaScript] ¿En qué orden salen los mensajes?
setTimeout(() => console.log("A"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("C");

[JavaScript] ¿Qué muestra la consola en Node?
async function obtener() {
  return 5;
}
const x = obtener();
console.log(x);

[JavaScript] Tienes que cargar 3 productos que no dependen entre sí y cada petición tarda 1 segundo. ¿Qué opción tarda unos 1 segundo en total?

[JavaScript] ¿Qué combinador usarías para saber qué peticiones fueron bien y cuáles fallaron, sin que un fallo cancele el resultado?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.