Errores y asincronía

Lección 7 de 8. Dos temas que van juntos: qué hacer cuando algo falla y cómo esperar cosas que tardan, como un temporizador o una petición a un servidor, sin bloquear el programa. Aquí está la pieza que más distingue a JavaScript: el bucle de eventos.

Errores: try, catch y finally

Cuando algo falla, JavaScript lanza una excepción: un objeto con el tipo de error y un mensaje. Si nadie la captura, el programa se detiene. Para capturarla, el código que puede fallar va en un try:

function leerConfig(texto) {
  try {
    const config = JSON.parse(texto);
    return config;
  } catch (e) {
    console.log("no es JSON válido:", e.name, "-", e.message);
    return {};
  } finally {
    console.log("finally se ejecuta siempre");
  }
}

console.log(leerConfig('{"puerto": 8080}'));
console.log(leerConfig("hola"));
salidafinally se ejecuta siempre
{ puerto: 8080 }
no es JSON válido: SyntaxError - Unexpected token 'h', "hola" is not valid JSON
finally se ejecuta siempre
{}

catch recibe el error y decide qué hacer; finally se ejecuta pase lo que pase, incluso si hay un return dentro del try (fíjate en que su mensaje sale antes que el resultado: corre justo antes de que la función devuelva), y sirve para cerrar archivos, conexiones o indicadores de «cargando». Cada error tiene un name (SyntaxError, TypeError, RangeError…) y un message.

Lanzar errores propios

class ErrorValidacion extends Error {
  constructor(campo, mensaje) {
    super(mensaje);
    this.name = "ErrorValidacion";
    this.campo = campo;
  }
}

function validarEdad(edad) {
  if (!Number.isInteger(edad)) throw new ErrorValidacion("edad", "debe ser un número entero");
  if (edad < 0) throw new ErrorValidacion("edad", "no puede ser negativa");
  return edad;
}

function guardarUsuario(datos) {
  try {
    validarEdad(datos.edad);
  } catch (e) {
    throw new Error(`No se pudo guardar a ${datos.nombre}`, { cause: e });
  }
}

try {
  guardarUsuario({ nombre: "Ana", edad: -3 });
} catch (e) {
  console.log(e.message);
  console.log("causa:", e.cause.name, e.cause.campo, e.cause.message);
  console.log(e.cause instanceof ErrorValidacion);
}
salidaNo se pudo guardar a Ana
causa: ErrorValidacion edad no puede ser negativa
true

Una clase que hereda de Error (lo visto en la lección 6) permite distinguir los errores con instanceof y llevar datos extra, como el campo que falló. Y la opción cause encadena errores: el de alto nivel explica qué se intentaba hacer y el de dentro, por qué falló, sin perder ninguno de los dos.

Lanza siempre objetos Error, nunca cadenas. throw "algo falló" es válido, pero una cadena no tiene name, ni message, ni la traza que dice en qué línea pasó. Y un catch vacío, que se traga el error sin hacer nada, es la forma más segura de pasarte una tarde buscando un fallo que el programa ya te había avisado.

Un solo hilo y un bucle de eventos

JavaScript ejecuta tu código en un solo hilo: hace una cosa cada vez. Y sin embargo, un servidor de Node atiende a miles de clientes y una página web no se congela mientras descarga datos. El truco es que las esperas, como un temporizador, una petición de red o la lectura de un archivo, no bloquean: se encargan al sistema y, cuando terminan, dejan una tarea en una cola. Mira el orden de esta salida:

console.log("1. síncrono");

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

Promise.resolve().then(() => console.log("3. then de una promesa"));
queueMicrotask(() => console.log("3. queueMicrotask"));

console.log("2. síncrono, al final del archivo");
salida1. síncrono
2. síncrono, al final del archivo
3. then de una promesa
3. queueMicrotask
4. setTimeout de 0 ms

Bucle de eventos: cuando la pila de llamadas se vacía, primero se ejecutan todas las microtareas y después una tarea de la cola de tareasEl bucle de eventos: quién se ejecuta y cuándopila de llamadasel código que corre ahoramain()console.log(1)solo hay unacola de microtareasthen, await, queueMicrotaskthen(…)await …1.ºcola de tareassetTimeout, eventos, E/SsetTimeoutclic2.ºcuando la pilaqueda vacíaPrimero se vacía TODA la cola de microtareas;después entra UNA tarea, y se repite el ciclo.

El código síncrono corre de principio a fin en la pila. Cuando la pila se vacía, el bucle de eventos ejecuta todas las microtareas pendientes (promesas) y luego una tarea (temporizadores, eventos), y vuelve a empezar. Esquema: decodigo.com.

El setTimeout de 0 milisegundos no se ejecuta «ahora»: se ejecuta cuando la pila queda libre y ya no quedan microtareas. Las promesas van en una cola con prioridad, la de microtareas, que se vacía entera antes de pasar a la siguiente tarea. Esto explica también el bucle de la lección 4: los setTimeout se ejecutaron cuando el bucle ya había terminado.

La consecuencia práctica: un cálculo largo y síncrono lo congela todo. Mientras el hilo esté ocupado en un bucle de diez segundos, no se atiende ningún clic, ni ninguna petición, ni ningún temporizador.

Promesas

Una promesa es un objeto que representa un resultado que todavía no está: «te daré un valor, o un error, más adelante». Casi nunca se crean a mano, porque las funciones asíncronas ya las devuelven, pero ver una por dentro ayuda. Esta convierte setTimeout en algo que se puede esperar:

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

function dividir(a, b) {
  return new Promise((resolve, reject) => {
    if (b === 0) reject(new RangeError("división por cero"));
    else resolve(a / b);
  });
}

esperar(100, "listo")
  .then((v) => console.log("then:", v))
  .then(() => dividir(10, 0))
  .then((r) => console.log("esto no se ejecuta", r))
  .catch((e) => console.log("catch:", e.message))
  .finally(() => console.log("fin de la cadena"));
salidathen: listo
catch: división por cero
fin de la cadena

Una promesa empieza pendiente y pasa una sola vez a cumplida, con resolve, o a rechazada, con rejectLos tres estados de una promesapendientependingcumplidafulfilled: tiene un valorresolve(valor)rechazadarejected: tiene un errorreject(error)una promesa cambia de estado una sola vez, y ya no vuelve atrás

Una promesa nace pendiente y termina una sola vez: cumplida con un valor o rechazada con un error. Esquema: decodigo.com.

Cada then devuelve una promesa nueva, así que se encadenan. Si cualquier paso de la cadena falla, se saltan los then que quedan y se va directo al catch, igual que un try salta al suyo.

async y await

Las cadenas de then funcionan, pero se leen al revés de como se piensa. async y await permiten escribir lo mismo como si fuera código normal, de arriba abajo:

async function cargarPedido(id) {
  console.log("pidiendo el pedido", id);
  const pedido = await esperar(100, { id, lineas: ["pan", "café"] });
  console.log("llegó con", pedido.lineas.length, "líneas");
  return pedido;
}

async function main() {
  try {
    const p = await cargarPedido(7);
    console.log("pedido", p.id, "listo");
    await dividirAsync(1, 0);
  } catch (e) {
    console.log("error capturado:", e.message);
  }
}

async function dividirAsync(a, b) {
  if (b === 0) throw new RangeError("división por cero");
  return a / b;
}

main();
salidapidiendo el pedido 7
llegó con 2 líneas
pedido 7 listo
error capturado: división por cero

Tres reglas cubren casi todo. Una función async siempre devuelve una promesa: lo que devuelve con return es el valor con el que se cumple, y lo que lanza con throw, el error con el que se rechaza. await pausa esa función, y solo esa, hasta que la promesa termina: el resto del programa sigue funcionando. Y los errores de un await se capturan con try...catch, como cualquier otro. En los módulos (archivos .mjs o con "type": "module", que se ven en la lección 8) se puede usar await directamente en el nivel superior, fuera de cualquier función.

El error frecuente: olvidar el await

async function precioActual() {
  await esperar(50);
  return 7.5;
}

const precio = precioActual();          // falta el await
console.log("precio:", precio);
console.log("con IVA:", precio * 1.16);
console.log("bien:", await precioActual());
salidaprecio: Promise { <pending> }
con IVA: NaN
bien: 7.5

Sin await, lo que tienes no es el precio sino la promesa del precio, todavía pendiente. Y multiplicar una promesa da NaN, sin ningún error que te avise. Si ves Promise { <pending> } o un NaN inesperado cerca de una función asíncrona, busca el await que falta.

El caso contrario también es frecuente: una promesa que se rechaza y que nadie captura. Node lo trata como un error sin capturar y termina el programa:

async function guardar() {
  throw new Error("disco lleno");
}
guardar();
console.log("esta línea sí se ejecuta");
salidaesta línea sí se ejecuta
salidaError: disco lleno

Varias cosas a la vez: Promise.all

Si tienes que esperar varias cosas que no dependen unas de otras, no las esperes en fila:

const ids = [1, 2, 3];
const pedir = (id) => esperar(200, `pedido ${id}`);
const ms = (t0) => Math.round((performance.now() - t0) / 100) * 100;

let t0 = performance.now();
for (const id of ids) {
  await pedir(id);                          // espera cada uno antes de pedir el siguiente
}
console.log("en fila:", ms(t0), "ms");

t0 = performance.now();
const todos = await Promise.all(ids.map(pedir));   // los tres a la vez
console.log("a la vez:", ms(t0), "ms", todos);
salidaen fila: 600 ms
a la vez: 200 ms [ 'pedido 1', 'pedido 2', 'pedido 3' ]

Con await en un bucle las tres esperas van una detrás de otra y suman 600 ms; con Promise.all van a la vez y tardan 200 msTres esperas de 200 ms: una tras otra o a la vezawait en un buclepedido 1pedido 2pedido 3600 msPromise.allpedido 1pedido 2pedido 3200 ms0 ms200 ms400 ms600 ms

Con await dentro del bucle, cada espera empieza cuando acaba la anterior. Con Promise.all empiezan las tres a la vez y el tiempo total es el de la más lenta. Esquema: decodigo.com.
const fallaSiImpar = (n) =>
  n % 2 ? Promise.reject(new Error(`el ${n} falló`)) : esperar(50, n * 10);

try {
  await Promise.all([2, 3, 4].map(fallaSiImpar));
} catch (e) {
  console.log("all:", e.message);
}

const resultados = await Promise.allSettled([2, 3, 4].map(fallaSiImpar));
for (const r of resultados) {
  console.log(r.status, r.value ?? r.reason.message);
}

const lenta = esperar(500, "respuesta");
const limite = new Promise((_, reject) => setTimeout(() => reject(new Error("tiempo agotado")), 100));
try {
  await Promise.race([lenta, limite]);
} catch (e) {
  console.log("race:", e.message);
}
salidaall: el 3 falló
fulfilled 20
rejected el 3 falló
fulfilled 40
race: tiempo agotado
FunciónSe cumple cuando…Se rechaza cuando…
Promise.allse cumplen todas (da un array con los valores)falla una cualquiera
Promise.allSettledterminan todas, bien o mal (da el estado de cada una)nunca
Promise.racela primera que termine se cumplela primera que termine falla
Promise.anyse cumple la primerafallan todas

Promise.race con una promesa que falla a los 100 ms es la forma clásica de ponerle un tiempo límite a algo.

fetch: pedir datos a un servidor

Todo lo anterior se junta en fetch, la función para hacer peticiones HTTP, que existe igual en el navegador y en Node. Devuelve una promesa con la respuesta, y leer el cuerpo de la respuesta es otra promesa:

async function producto(id) {
  const res = await fetch(`http://localhost:3000/api/productos/${id}`);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} al pedir el producto ${id}`);
  }
  return res.json();
}

console.log(await producto(7));

try {
  await producto(99);
} catch (e) {
  console.log(e.message);
}
salida{ id: 7, nombre: 'café', precio: 7.5 }
HTTP 404 al pedir el producto 99
Un 404 o un 500 no rechazan la promesa de fetch. Para fetch, el servidor respondió, así que todo fue bien; solo rechaza si no hay respuesta (sin red, servidor caído, dirección mal escrita). Por eso hay que comprobar res.ok a mano, como arriba: si no, intentarías leer como JSON una página de error.

Para el ejemplo, un pequeño servidor de node:http responde en el puerto 3000 (no se muestra aquí). Peticiones POST, cabeceras y tiempos límite están en el ejemplo peticiones HTTP con fetch, y un servidor completo, en servidor web con Node.

Todo el código de esta lección se ejecutó con Node.js 24 en un contenedor limpio antes de publicarla; las salidas y los errores están copiados de esa ejecución.