Terminaste la lección anterior con una pirámide de compra que funcionaba, pero cuyo coste era evidente: un objeto contexto para arrastrar el estado a mano, dos puntos de salida de error, una compensación anidada dentro de otro callback y la imposibilidad de lanzar en paralelo lo que era independiente. El diagnóstico fue claro: el lenguaje no ayudaba. try/catch no funcionaba, return no servía, finally no existía.
Las promesas devuelven todo eso. Una promesa es un objeto que representa un valor que todavía no está disponible, y que el lenguaje entiende: puede encadenarse, componerse, esperarse con await y capturarse con try/catch. Sobre ellas, async/await añade una capa de sintaxis que hace que el código asíncrono se lea exactamente igual que el síncrono, sin serlo.
En esta lección vas a entender qué es realmente una promesa y sus tres estados, a convertir con util.promisify las funciones de callback que escribiste ayer, a reescribir el flujo de compra de Escena Viva hasta dejarlo plano y legible, y a dominar la distinción que separa al principiante del profesional: cuándo esperar en serie y cuándo lanzar en paralelo. Terminarás con los patrones que usarás cada día —reintentos, tiempos límite y una función dormir— y sabiendo por qué un rechazo no manejado tumba tu proceso en Node moderno.
Contenido
- Qué es una promesa y sus tres estados
- Consumir promesas:
.then,.catchy.finally - El encadenamiento plano frente a la pirámide
- Crear promesas:
new Promiseyutil.promisify asyncyawait: las tres reglas- El flujo de compra reescrito, lado a lado
- Secuencial frente a concurrente: el error del
forconawait Promise.all,allSettled,raceyany- Propagación de errores y rechazos no manejados
awaitde nivel superior- Patrones útiles:
dormir, reintentos y tiempo límite - Tabla resumen: callbacks, promesas y
async/await
- Qué es una promesa y sus tres estados
Una promesa (Promise) es un objeto que representa el resultado futuro de una operación asíncrona. No es el valor: es un recibo que puedes guardar, pasar a otras funciones y consultar, y que en algún momento se convertirá en un valor o en un error.
Una promesa está siempre en uno de tres estados:
stateDiagram-v2
[*] --> Pendiente: se crea la promesa
Pendiente --> Cumplida: resolve(valor)
Pendiente --> Rechazada: reject(error)
Cumplida --> [*]: .then(valor)
Rechazada --> [*]: .catch(error)
note right of Pendiente
La operación está en curso.
No hay valor todavía.
end note
note right of Cumplida
fulfilled: hay un valor.
Estado FINAL.
end note
note right of Rechazada
rejected: hay un error.
Estado FINAL.
end note
| Estado | Nombre en inglés | Significado |
|---|---|---|
| Pendiente | pending | La operación aún no ha terminado |
| Cumplida | fulfilled | Terminó bien y hay un valor |
| Rechazada | rejected | Terminó mal y hay un motivo (normalmente un Error) |
Y tres propiedades que definen su comportamiento y que hay que grabar a fuego:
- Una promesa cambia de estado exactamente una vez. De pendiente pasa a cumplida o a rechazada, y ahí se queda para siempre. Se dice que está asentada (settled). Esto resuelve por decreto el problema de "el callback se llamó dos veces": es imposible.
- El resultado es inmutable. Una vez cumplida con un valor, ese valor no cambia.
- Puedes suscribirte cuando quieras, incluso tarde. Si te suscribes a una promesa ya cumplida, tu función se ejecuta igualmente (en la cola de microtareas). Con un callback, si llegas tarde, te lo has perdido.
Puedes verlo en el REPL, que ya conoces de la lección El REPL de Node.js:
> const p = new Promise((resolve) => setTimeout(() => resolve('listo'), 2000));
> p
Promise { <pending> } // Antes de los 2 segundos
// ... esperas dos segundos ...
> p
Promise { 'listo' } // Ya asentada, y con su valor visible
> Promise.reject(new Error('fallo'))
Promise { <rejected> Error: fallo ... }
- Consumir promesas:
.then, .catch y .finally
.then, .catch y .finallyUna promesa se consume con tres métodos:
// src/laboratorio/consumir-promesa.js
const fs = require('node:fs/promises'); // La version con promesas de fs
fs.readFile('datos/eventos.json', 'utf8')
.then((contenido) => {
// Se ejecuta si la promesa se CUMPLE. Recibe el valor.
const catalogo = JSON.parse(contenido);
console.log(`Cargados ${catalogo.length} eventos`);
})
.catch((error) => {
// Se ejecuta si la promesa se RECHAZA en cualquier punto anterior.
console.error(`No se pudo cargar el catalogo: ${error.message}`);
})
.finally(() => {
// Se ejecuta SIEMPRE, haya ido bien o mal. Sin argumentos.
console.error('[catalogo] intento de carga finalizado');
});Fíjate en node:fs/promises: Node ofrece versiones con promesas de sus módulos principales. fs/promises, dns/promises, timers/promises y stream/promises existen precisamente para no tener que convertir nada a mano.
| Método | Cuándo se ejecuta | Qué recibe | Qué devuelve |
|---|---|---|---|
.then(fn) |
Al cumplirse | El valor | Una nueva promesa |
.catch(fn) |
Al rechazarse | El motivo del rechazo | Una nueva promesa |
.finally(fn) |
Siempre, al asentarse | Nada | Una promesa con el mismo resultado |
Esa columna de la derecha es la clave de todo lo que viene: cada método devuelve una promesa nueva, y eso es lo que permite encadenar.
Dos matices sobre .finally que se olvidan a menudo:
- No recibe argumentos, porque no sabe (ni le importa) si hubo éxito o error. Es para limpieza: cerrar un fichero, quitar un indicador de carga, liberar un recurso.
- No altera el resultado. Si la promesa se rechazó, sigue rechazada después del
finally.
- El encadenamiento plano frente a la pirámide
Aquí está la primera gran ganancia. Recuerda la forma de la pirámide:
// CALLBACKS: cada paso dentro del anterior.
buscarEvento(eventoId, (error, evento) => {
if (error) return console.error(error.message);
comprobarAforo(sesionId, cantidad, (error, sesion) => {
if (error) return console.error(error.message);
crearPedido(usuarioId, sesion, cantidad, (error, pedido) => {
if (error) return console.error(error.message);
console.log(pedido.id);
});
});
});Y ahora con promesas:
// PROMESAS: cada paso al MISMO nivel, con un unico catch al final.
buscarEvento(eventoId)
.then((evento) => comprobarAforo(sesionId, cantidad))
.then((sesion) => crearPedido(usuarioId, sesion, cantidad))
.then((pedido) => console.log(pedido.id))
.catch((error) => console.error(error.message));La regla que hace esto posible es sencilla y potente:
Si dentro de un
.thendevuelves una promesa, la cadena espera a que se asiente antes de continuar.
Y la segunda regla, igual de importante:
Un rechazo en cualquier punto de la cadena salta todos los
.thensiguientes hasta encontrar un.catch.
flowchart LR
A["buscarEvento"] -->|"cumplida"| B[".then<br/>comprobarAforo"]
B -->|"cumplida"| C[".then<br/>crearPedido"]
C -->|"cumplida"| D[".then<br/>mostrar"]
D --> E[".catch"]
A -.->|"rechazada"| E
B -.->|"rechazada"| E
C -.->|"rechazada"| E
D -.->|"rechazada"| E
Un único punto de tratamiento de errores para toda la cadena. Compáralo con los cinco if (error) return de la lección anterior.
El error clásico: olvidar el return
// MAL: la cadena NO espera a crearPedido.
buscarEvento(eventoId)
.then((evento) => {
crearPedido(usuarioId, evento, 2); // Falta el return
})
.then((pedido) => {
console.log(pedido.id); // TypeError: pedido es undefined
});
// BIEN
buscarEvento(eventoId)
.then((evento) => {
return crearPedido(usuarioId, evento, 2);
})
.then((pedido) => {
console.log(pedido.id);
});
// BIEN, en forma corta: la flecha sin llaves devuelve implicitamente.
buscarEvento(eventoId)
.then((evento) => crearPedido(usuarioId, evento, 2))
.then((pedido) => console.log(pedido.id));Este es a las promesas lo que el return tras el error era a los callbacks: el fallo número uno. La forma corta con flecha sin llaves lo evita de raíz, y por eso se prefiere.
- Crear promesas:
new Promise y util.promisify
new Promise y util.promisify4.1 new Promise
El constructor recibe una función —llamada ejecutor— con dos parámetros: resolve y reject.
// src/utiles/dormir.js
// Devuelve una promesa que se cumple pasados los milisegundos indicados.
function dormir(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
module.exports = { dormir };El ejecutor se ejecuta inmediatamente y de forma síncrona al construir la promesa. Lo asíncrono es el setTimeout de dentro, no el constructor.
Una versión con rechazo, aplicada a Escena Viva:
// Simula el cobro con la pasarela de pago.
function cobrarPedido(pedido) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (pedido.totalCentimos > 20000) {
const error = new Error(
`Pago rechazado: ${(pedido.totalCentimos / 100).toFixed(2)} EUR supera el limite`
);
error.codigo = 'PAGO_RECHAZADO';
reject(error);
return;
}
resolve({ ...pedido, estado: 'pagado' });
}, 40);
});
}Reglas del constructor:
resolveyrejectno interrumpen la función. Igual que con los callbacks, pon unreturndespués.rejectsiempre con un objetoError.reject('algo falló')es legal y es mala práctica: pierdes la traza.- Una excepción lanzada dentro del ejecutor se convierte en un rechazo automáticamente. Esta sí la captura la promesa.
Antipatrón importante: el "constructor antipattern". Si ya tienes una promesa, no la envuelvas en otra.
// MAL function cargar() { return new Promise((resolve, reject) => { fs.readFile('a.json', 'utf8').then(resolve).catch(reject); }); } // BIEN function cargar() { return fs.readFile('a.json', 'utf8'); }
new Promisees solo para envolver algo que todavía no es una promesa: un callback, un evento, un temporizador.
4.2 util.promisify: convertir los callbacks de ayer
Aquí está la herramienta que convierte todo el trabajo de la lección anterior. util.promisify toma una función que sigue la convención error-first y devuelve otra que devuelve una promesa.
// src/laboratorio/promisificar.js
const { promisify } = require('node:util');
// Las funciones de la leccion anterior, con callback error-first.
// function buscarEvento(id, callback) { ... }
// function buscarSesion(sesionId, callback) { ... }
// function reservarEntradas(sesionId, cantidad, callback) { ... }
const buscarEventoP = promisify(buscarEvento);
const buscarSesionP = promisify(buscarSesion);
const reservarEntradasP = promisify(reservarEntradas);
// Y ya se usan como promesas.
buscarEventoP('evt-002')
.then((evento) => console.log(`Encontrado: ${evento.titulo}`))
.catch((error) => console.error(`[${error.codigo}] ${error.message}`));Cómo funciona por dentro, para que no sea magia:
// Version simplificada de lo que hace promisify.
function promisificar(funcionConCallback) {
return function (...argumentos) {
return new Promise((resolve, reject) => {
funcionConCallback(...argumentos, (error, resultado) => {
if (error) {
reject(error);
return;
}
resolve(resultado);
});
});
};
}Requisitos para que promisify funcione:
| Requisito | Si no se cumple |
|---|---|
| El callback es el último parámetro | No funciona |
El callback tiene la firma (error, resultado) |
El valor resuelto será incorrecto |
| El callback se llama una sola vez | Las llamadas extra se ignoran (la promesa ya está asentada) |
Fíjate en esa última fila: la promesa te protege del bug de "llamar dos veces al callback". Es una de esas ventajas silenciosas que se agradecen mucho.
Si la función devuelve varios valores (no es error-first estándar), existe util.promisify.custom para definir la conversión a mano. Es poco frecuente; cuando lo necesites, la documentación de node:util lo cubre.
async y await: las tres reglas
async y await: las tres reglasasync/await no es un mecanismo nuevo: es azúcar sintáctico sobre promesas. Por debajo hay exactamente las mismas promesas y la misma cola de microtareas. Lo que cambia es cómo se escribe.
Regla 1: una función async siempre devuelve una promesa
async function obtenerTitulo() {
return 'Concierto de Otono'; // Devolvemos una cadena...
}
console.log(obtenerTitulo()); // Promise { 'Concierto de Otono' }
obtenerTitulo().then((t) => console.log(t)); // Concierto de OtonoAunque devuelvas un número, undefined o nada, el resultado es una promesa. Y si lanzas una excepción dentro, la promesa se rechaza en lugar de propagar la excepción:
async function fallar() {
throw new Error('algo salio mal');
}
fallar().catch((error) => console.error(error.message)); // algo salio malRegla 2: await solo pausa la función que lo contiene
Esta es la regla que más se malinterpreta. await no bloquea el proceso ni el hilo. Pausa únicamente la función async donde aparece; el resto del programa sigue funcionando con normalidad.
// src/laboratorio/await-no-bloquea.js
const { dormir } = require('../utiles/dormir.js');
async function tareaLenta() {
console.log(' [lenta] empiezo');
await dormir(300);
console.log(' [lenta] termino');
}
// Un latido que demuestra que el proceso sigue vivo.
const latido = setInterval(() => console.log('latido'), 100);
tareaLenta().then(() => clearInterval(latido));
console.log('Esta linea se ejecuta ANTES de que tareaLenta termine');Durante los 300 ms de espera el bucle de eventos siguió girando y disparando el temporizador. Compáralo con el bloqueo síncrono de la lección de arquitectura, donde el latido desaparecía por completo. await cede el control; un bucle while lo retiene.
Y await solo es válido dentro de una función async (o en el nivel superior de un módulo ES, apartado 10):
// SyntaxError: await is only valid in async functions
function mal() {
const evento = await buscarEventoP('evt-001');
}Regla 3: try/catch vuelve a funcionar
Esta es la razón por la que existe async/await.
// src/laboratorio/try-catch-funciona.js
async function mostrarEvento(id) {
try {
const evento = await buscarEventoP(id);
console.log(`Encontrado: ${evento.titulo}`);
} catch (error) {
// SI se captura. El await convierte el rechazo en una excepcion normal.
console.error(`[${error.codigo}] ${error.message}`);
} finally {
// Y finally tambien funciona: aqui va la limpieza.
console.error(`[consulta] terminada para ${id}`);
}
}
mostrarEvento('evt-999');Recuperar try/catch/finally no es solo comodidad sintáctica: significa que el modelo de errores del lenguaje vuelve a aplicarse al código asíncrono. Los errores se propagan hacia arriba por la pila de llamadas async, se capturan donde tenga sentido capturarlos, y finally garantiza la limpieza. Toda la contabilidad manual de la lección anterior desaparece.
- El flujo de compra reescrito, lado a lado
Momento de cobrar el premio. Este es el mismo flujo del ejercicio 3 de la lección anterior, ahora con async/await.
Antes, con callbacks (resumido a su estructura):
function comprarEntradas(usuarioId, eventoId, sesionId, cantidad, alTerminar) {
const contexto = { usuarioId, eventoId, sesionId, cantidad, aforoReservado: false };
buscarEvento(eventoId, alEncontrarEvento);
function alEncontrarEvento(error, evento) {
if (error) return fallo('buscar-evento', error);
contexto.evento = evento;
reservarEntradas(sesionId, cantidad, alReservar);
}
function alReservar(error, reserva) { /* ... */ }
function alBuscarSesion(error, resultado) { /* ... */ }
function alCrearPedido(error, pedido) { /* ... */ }
function alCobrar(error, pedidoPagado) { /* ... */ }
function alEmitir(error, entradas) { /* ... */ }
function fallo(paso, error) { /* ... */ }
function compensarYFallar(paso, error) {
liberarAforo(sesionId, cantidad, (errorLiberar) => { /* ... */ });
}
}Después, con async/await:
// src/laboratorio/compra-async.js
// El mismo flujo de compra, con promesas. 30 lineas en lugar de 70.
async function comprarEntradas(usuarioId, eventoId, sesionId, cantidad) {
const evento = await buscarEventoP(eventoId);
// A partir de aqui hay aforo reservado que puede haber que devolver.
const reserva = await reservarEntradasP(sesionId, cantidad);
try {
const { sesion } = await buscarSesionP(sesionId);
const pedido = await crearPedidoP(usuarioId, sesion, cantidad);
const pedidoPagado = await cobrarPedido(pedido);
const entradas = await emitirEntradasP(pedidoPagado);
return { evento, sesion, pedido: pedidoPagado, entradas };
} catch (error) {
// Compensacion: liberamos el aforo que habiamos reservado.
const libres = await liberarAforoP(sesionId, cantidad);
console.error(`[compensacion] aforo liberado en ${sesionId}, quedan ${libres} libres`);
// Y relanzamos: quien nos llamo decide que hacer.
throw error;
}
}Y su uso:
async function principal() {
try {
const compra = await comprarEntradas('asis-001', 'evt-002', 'ses-002-2', 3);
console.log(`Pedido ${compra.pedido.id} - ${compra.pedido.estado}`);
console.log(` Evento : ${compra.evento.titulo}`);
console.log(` Importe: ${(compra.pedido.totalCentimos / 100).toFixed(2)} EUR`);
for (const entrada of compra.entradas) {
console.log(` ${entrada.codigo} ${entrada.estado}`);
}
} catch (error) {
console.error(`[${error.codigo || 'ERROR'}] ${error.message}`);
process.exitCode = 1;
}
}
principal();Compara punto por punto:
| Aspecto | Callbacks | async/await |
|---|---|---|
| Líneas del flujo | ~70 | ~30 |
| Niveles de indentación | 2 (con la técnica de aplanado) | 1 |
Objeto contexto para el estado |
Necesario | Innecesario: son variables locales normales |
| Puntos de tratamiento de errores | 2 (fallo y compensarYFallar) |
1 (catch) |
| Compensación | Callback anidado con su propio error | 2 líneas dentro del catch |
| Devolver el resultado | Imposible; hay que pasar un callback | return normal |
| Orden de lectura | Saltando entre funciones | De arriba abajo |
Y fíjate en el detalle más elegante: evento, reserva, sesion, pedido y entradas son variables locales corrientes, visibles en todo el cuerpo de la función. Aquello de arrastrar el contexto por cinco callbacks ha desaparecido, y no porque hayamos sido más listos: porque el lenguaje ahora entiende la espera.
- Secuencial frente a concurrente: el error del
for con await
for con awaitasync/await es tan cómodo que induce a un error de rendimiento muy frecuente y muy caro. Míralo:
// src/laboratorio/secuencial-vs-paralelo.js
// MAL: los tres eventos son independientes, pero se cargan en serie.
async function cargarEventosEnSerie(ids) {
const eventos = [];
for (const id of ids) {
const evento = await buscarEventoP(id); // Espera a que termine el anterior
eventos.push(evento);
}
return eventos;
}
const inicio = Date.now();
cargarEventosEnSerie(['evt-001', 'evt-002', 'evt-003']).then((eventos) => {
console.log(`En serie: ${eventos.length} eventos en ${Date.now() - inicio} ms`);
});Con 40 ms de latencia por consulta, tres consultas en serie cuestan 120 ms. Pero los tres eventos no dependen unos de otros: no hay ninguna razón para esperar a que llegue el primero antes de pedir el segundo.
// BIEN: las tres peticiones salen a la vez.
async function cargarEventosEnParalelo(ids) {
// map devuelve un array de PROMESAS: las tres operaciones ya han empezado.
const promesas = ids.map((id) => buscarEventoP(id));
// Promise.all espera a que TODAS se cumplan.
return Promise.all(promesas);
}
const inicio2 = Date.now();
cargarEventosEnParalelo(['evt-001', 'evt-002', 'evt-003']).then((eventos) => {
console.log(`En paralelo: ${eventos.length} eventos en ${Date.now() - inicio2} ms`);
});Tres veces más rápido, y con tres eventos. Con treinta, la diferencia sería de 1,2 segundos frente a 40 milisegundos.
gantt
dateFormat SSS
axisFormat %L ms
title Tres consultas de 40 ms
section En serie (await en bucle)
evt-001 :a1, 000, 40ms
evt-002 :a2, after a1, 40ms
evt-003 :a3, after a2, 40ms
section En paralelo (Promise.all)
evt-001 :b1, 000, 40ms
evt-002 :b2, 000, 40ms
evt-003 :b3, 000, 40ms
La clave para entenderlo: una promesa empieza a trabajar en el momento en que se crea, no cuando se le hace await. El map crea las tres promesas de golpe, así que las tres operaciones arrancan a la vez; Promise.all solo se encarga de esperar.
Cuándo cada una
| Situación | Qué usar |
|---|---|
| El paso B necesita el resultado del paso A | await secuencial. No hay alternativa |
| Los pasos son independientes entre sí | Promise.all |
| Son independientes pero el destino no aguanta la carga (una API con límite) | Paralelismo acotado: lotes de N |
| Quieres el primero que responda | Promise.race o Promise.any |
En el flujo de compra del apartado 6, los await sí deben ser secuenciales: no puedes emitir entradas de un pedido que no has cobrado. En cambio, cargar el catálogo completo o consultar tres organizadores distintos es trabajo paralelo.
Paralelismo acotado
Lanzar 3.000 peticiones a la vez con Promise.all no es "más rápido": es una forma de tumbar la base de datos o de que la API externa te bloquee. El patrón correcto es procesar en lotes:
// src/utiles/en-lotes.js
// Ejecuta una tarea sobre muchos elementos, como maximo N a la vez.
async function enLotes(elementos, tamanoLote, tarea) {
const resultados = [];
for (let i = 0; i < elementos.length; i += tamanoLote) {
const lote = elementos.slice(i, i + tamanoLote);
// Dentro del lote, en paralelo. Entre lotes, en serie.
const resultadosLote = await Promise.all(lote.map(tarea));
resultados.push(...resultadosLote);
}
return resultados;
}
module.exports = { enLotes };// Consultar 3000 sesiones de 20 en 20.
const ocupaciones = await enLotes(idsSesiones, 20, (id) => consultarOcupacion(id));
Promise.all, allSettled, race y any
Promise.all, allSettled, race y anyLos cuatro combinadores de promesas. Elegir mal es una fuente habitual de errores sutiles.
| Método | Se cumple cuando… | Se rechaza cuando… | Devuelve |
|---|---|---|---|
Promise.all |
Todas se cumplen | Alguna se rechaza (la primera) | Array de valores, en el orden de entrada |
Promise.allSettled |
Todas se asientan (nunca se rechaza) | Nunca | Array de { status, value } o { status, reason } |
Promise.race |
La primera en asentarse se cumple | La primera en asentarse se rechaza | El valor o error de la primera |
Promise.any |
La primera en cumplirse | Todas se rechazan | El valor de la primera que funcionó |
Promise.all: todo o nada
const [almendra, boveda, ribera] = await Promise.all([
buscarEventoP('evt-001'),
buscarEventoP('evt-002'),
buscarEventoP('evt-003')
]);La desestructuración funciona porque el orden del resultado es el de entrada, no el de finalización.
Su comportamiento ante el fallo es el que hay que entender bien:
try {
const eventos = await Promise.all([
buscarEventoP('evt-001'),
buscarEventoP('evt-999'), // No existe: se rechaza
buscarEventoP('evt-003')
]);
} catch (error) {
console.error(error.message); // Evento no encontrado: evt-999
// Y de evt-001 y evt-003 no sabemos nada, aunque probablemente fueron bien.
}Promise.all es "todo o nada": al primer rechazo, la promesa combinada se rechaza y pierdes los resultados de las demás. Además, las otras operaciones no se cancelan: siguen ejecutándose, simplemente ya no interesan a nadie.
Úsalo cuando necesitas todos los resultados para continuar. Si falta uno, el trabajo no tiene sentido.
Promise.allSettled: quiero saberlo todo
// src/laboratorio/informe-resiliente.js
// Un informe que no debe caerse porque falle un evento.
const resultados = await Promise.allSettled([
buscarEventoP('evt-001'),
buscarEventoP('evt-999'),
buscarEventoP('evt-003')
]);
const encontrados = [];
const fallidos = [];
for (const resultado of resultados) {
if (resultado.status === 'fulfilled') {
encontrados.push(resultado.value.titulo);
} else {
fallidos.push(resultado.reason.message);
}
}
console.log(`Encontrados (${encontrados.length}): ${encontrados.join(', ')}`);
console.error(`Fallidos (${fallidos.length}): ${fallidos.join(' | ')}`);Encontrados (2): Concierto de Otono, Festival de Jazz de Primavera Fallidos (1): Evento no encontrado: evt-999
allSettled nunca se rechaza. Es la elección correcta para informes, sincronizaciones y cualquier proceso por lotes donde un fallo parcial no debe invalidar el resto. En Escena Viva: enviar 500 correos de confirmación con Promise.all significa que un correo rebotado cancela el informe de los otros 499; con allSettled, sabes exactamente cuáles se enviaron y cuáles no.
Promise.race: el primero que llegue, para bien o para mal
// Tiempo limite: o responde la pasarela, o se rechaza a los 3 segundos.
const pedidoPagado = await Promise.race([
cobrarPedido(pedido),
rechazarTras(3000, 'La pasarela de pago no responde')
]);Su uso principal es exactamente ese: imponer un tiempo límite. Lo desarrollamos en el apartado 11.
Cuidado con una trampa: race se asienta con la primera promesa que se asiente, incluso si es un rechazo. Si quieres "el primero que funcione", ignorando fallos, necesitas any.
Promise.any: el primero que funcione
// Consultar el precio a tres proveedores; nos vale el primero que responda bien.
try {
const tipoCambio = await Promise.any([
consultarProveedorA(),
consultarProveedorB(),
consultarProveedorC()
]);
console.log(`Tipo de cambio obtenido: ${tipoCambio}`);
} catch (error) {
// AggregateError: contiene TODOS los errores en error.errors
console.error(`Ningun proveedor respondio (${error.errors.length} fallos)`);
for (const fallo of error.errors) {
console.error(` - ${fallo.message}`);
}
}Promise.any solo se rechaza si todas fallan, y lo hace con un AggregateError que contiene el array completo de errores en .errors. Es el patrón de redundancia: varias réplicas o varios proveedores para lo mismo.
Guía rápida de decisión
| Quiero… | Uso |
|---|---|
| Cargar los datos que necesito para responder, y si falta uno no puedo responder | Promise.all |
| Procesar un lote donde los fallos parciales son aceptables y hay que reportarlos | Promise.allSettled |
| Poner un tiempo límite a una operación | Promise.race |
| Consultar varias fuentes redundantes y quedarme con la primera que funcione | Promise.any |
- Propagación de errores y rechazos no manejados
9.1 Los errores suben por la pila async
async function nivel3() {
throw new Error('fallo en el nivel mas profundo');
}
async function nivel2() {
await nivel3(); // El rechazo se propaga hacia arriba
console.log('Esto no se ejecuta');
}
async function nivel1() {
try {
await nivel2();
} catch (error) {
console.error(`Capturado en nivel1: ${error.message}`);
}
}
nivel1(); // Capturado en nivel1: fallo en el nivel mas profundoExactamente igual que con código síncrono. Es la propiedad que hace que async/await sea tan cómodo: capturas los errores donde tienes contexto para decidir qué hacer, no en cada paso intermedio.
9.2 El rechazo no manejado tumba el proceso
Si una promesa se rechaza y nadie ha registrado un .catch ni la ha esperado dentro de un try, se produce un rechazo no manejado (unhandled rejection).
// src/laboratorio/rechazo-no-manejado.js
async function cobrar() {
throw new Error('La pasarela devolvio un error 500');
}
cobrar(); // Se llama, se ignora el resultado. NADIE captura el rechazo.
console.log('El script continua...');El script continua...
node:internal/process/promises:288
triggerUncaughtException(err, true /* fromPromise */);
^
Error: La pasarela devolvio un error 500
...
[el proceso muere con codigo 1]Desde Node.js 15, un rechazo no manejado termina el proceso. Antes solo imprimía un aviso, y muchas aplicaciones vivían con decenas de rechazos silenciosos que ocultaban errores reales. El cambio fue deliberado: un rechazo sin manejar es un error de programación, exactamente igual que una excepción sin capturar.
Los tres descuidos que lo provocan:
// 1. Llamar a una funcion async sin await ni .catch
procesarPedido(pedido); // MAL
await procesarPedido(pedido); // BIEN
procesarPedido(pedido).catch(registrarError); // BIEN, si no quieres esperar
// 2. Olvidar el catch en una cadena
buscarEventoP(id).then((e) => console.log(e.titulo)); // MAL
buscarEventoP(id).then((e) => console.log(e.titulo)).catch(log); // BIEN
// 3. La funcion principal sin proteger
async function principal() { /* ... */ }
principal(); // MAL
principal().catch((error) => { // BIEN
console.error(`Fallo fatal: ${error.message}`);
process.exitCode = 1;
});Como red de seguridad para el registro —no como manejo de errores— puedes escuchar el evento del proceso:
// src/utiles/red-de-seguridad.js
// Registra cualquier rechazo no manejado antes de que el proceso muera.
process.on('unhandledRejection', (motivo, promesa) => {
console.error('RECHAZO NO MANEJADO. Esto es un fallo de programacion.');
console.error(motivo instanceof Error ? motivo.stack : motivo);
process.exitCode = 1;
});Esto sirve para enterarte del problema y registrarlo en producción, no para seguir como si nada. Lo formalizaremos en el Módulo 11.
await de nivel superior
await de nivel superiorEn un módulo CommonJS —el que estamos usando y el que estudiaremos en Módulos CommonJS y require()— await solo puede aparecer dentro de una función async. Por eso hemos tenido que envolver todo en una función principal().
En un módulo ES, await funciona directamente en el nivel superior del fichero:
// src/laboratorio/carga.mjs (fijate en la extension .mjs)
import { readFile } from 'node:fs/promises';
// await directamente, sin envolver en ninguna funcion.
const contenido = await readFile('datos/eventos.json', 'utf8');
const catalogo = JSON.parse(contenido);
console.log(`Cargados ${catalogo.length} eventos`);Es una de las ventajas exclusivas de los módulos ES, y es especialmente cómoda para scripts, para inicialización de configuración y para el REPL (que, como viste en el Módulo 1, también lo admite).
Lo veremos en detalle —junto con cómo activarlo, sus reglas y su interoperabilidad con CommonJS— en la lección Módulos ES e Interoperabilidad.
- Patrones útiles:
dormir, reintentos y tiempo límite
dormir, reintentos y tiempo límiteTres utilidades que escribirás una vez y usarás siempre.
11.1 dormir(ms)
// src/utiles/dormir.js
function dormir(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = { dormir };Node trae una versión nativa desde la versión 15, en timers/promises:
const { setTimeout: dormir } = require('node:timers/promises');
await dormir(1000);
console.log('Un segundo despues');Usa la nativa cuando puedas: admite además una señal de cancelación (AbortSignal).
11.2 Reintentos con espera creciente
Las operaciones de red fallan de forma transitoria: un pico de latencia, un reinicio del servicio, un límite de peticiones momentáneo. Reintentar es a menudo la respuesta correcta.
// src/utiles/reintentar.js
// Reintenta una operacion asincrona con espera exponencial.
const { setTimeout: dormir } = require('node:timers/promises');
async function reintentar(operacion, opciones = {}) {
const {
intentos = 3,
esperaInicialMs = 200,
factor = 2,
esReintentable = () => true
} = opciones;
let ultimoError;
for (let intento = 1; intento <= intentos; intento++) {
try {
return await operacion(intento);
} catch (error) {
ultimoError = error;
// Un error de negocio no se reintenta: reintentar un aforo
// insuficiente no lo va a arreglar.
if (!esReintentable(error) || intento === intentos) {
throw error;
}
// Espera exponencial: 200, 400, 800 ms...
const espera = esperaInicialMs * factor ** (intento - 1);
console.error(
`[reintento] intento ${intento}/${intentos} fallido (${error.message}). ` +
`Reintentando en ${espera} ms.`
);
await dormir(espera);
}
}
throw ultimoError;
}
module.exports = { reintentar };// Uso en Escena Viva: cobrar reintentando solo los fallos de red.
const CODIGOS_REINTENTABLES = new Set(['ETIMEDOUT', 'ECONNRESET', 'PASARELA_NO_DISPONIBLE']);
const pedidoPagado = await reintentar(
() => cobrarPedido(pedido),
{
intentos: 4,
esperaInicialMs: 250,
esReintentable: (error) => CODIGOS_REINTENTABLES.has(error.codigo)
}
);Fíjate en esReintentable. Es la parte más importante del patrón, y la que casi todo el mundo omite: reintentar un PAGO_RECHAZADO cuatro veces no solo es inútil, sino que puede duplicar cargos. Solo se reintenta lo que es transitorio.
En producción se añade además jitter: una pequeña variación aleatoria en la espera, para que mil clientes que fallaron a la vez no reintenten todos en el mismo milisegundo.
11.3 Tiempo límite con Promise.race
Una operación asíncrona que nunca responde es peor que una que falla: deja recursos ocupados indefinidamente.
// src/utiles/con-limite-de-tiempo.js
// Envuelve una promesa con un tiempo maximo de espera.
function conLimiteDeTiempo(promesa, limiteMs, mensaje = 'Tiempo de espera agotado') {
let temporizador;
const limite = new Promise((_, reject) => {
temporizador = setTimeout(() => {
const error = new Error(`${mensaje} (${limiteMs} ms)`);
error.codigo = 'TIEMPO_AGOTADO';
reject(error);
}, limiteMs);
});
// El primero que se asiente gana.
return Promise.race([promesa, limite]).finally(() => {
// Limpiamos el temporizador para no retener el proceso vivo.
clearTimeout(temporizador);
});
}
module.exports = { conLimiteDeTiempo };try {
const pedidoPagado = await conLimiteDeTiempo(
cobrarPedido(pedido),
3000,
'La pasarela de pago no responde'
);
console.log(`Cobrado: ${pedidoPagado.id}`);
} catch (error) {
if (error.codigo === 'TIEMPO_AGOTADO') {
console.error('Reintentar mas tarde o avisar al usuario.');
}
throw error;
}Ese .finally(() => clearTimeout(temporizador)) no es un detalle menor: sin él, el temporizador mantiene vivo el proceso hasta que venza, aunque la operación haya terminado en 20 ms. Es justo el mecanismo de cuenta de referencias que viste en la lección de arquitectura.
Y una advertencia honesta: el tiempo límite no cancela la operación subyacente. La petición HTTP sigue en marcha; simplemente dejas de esperarla. Para cancelar de verdad hace falta AbortController, que las APIs modernas de Node (fetch, fs/promises, timers/promises) sí aceptan.
- Tabla resumen: callbacks, promesas y
async/await
async/await| Criterio | Callbacks | Promesas (.then) |
async/await |
|---|---|---|---|
| Sintaxis | Funciones anidadas | Cadena de métodos | Lineal, como código síncrono |
| Legibilidad de flujos largos | Mala (pirámide) | Buena | Excelente |
| Manejo de errores | if (error) en cada paso |
Un .catch por cadena |
try/catch/finally nativo |
finally / limpieza |
Manual | .finally() |
finally nativo |
| Devolver un valor | Imposible | Sí (una promesa) | Sí, con return |
| Ejecución en paralelo | Manual y propensa a errores | Promise.all |
Promise.all + await |
| Se puede llamar dos veces | Sí, es un bug frecuente | Imposible | Imposible |
| Variables entre pasos | Cierres anidados o contexto |
Cierres o encadenado | Variables locales normales |
| Depuración y trazas | Trazas pobres | Mejores | Trazas async completas |
| Múltiples resultados a lo largo del tiempo | Sí | No (se asienta una vez) | No |
| Coste de rendimiento | El menor | Cola de microtareas | Como las promesas |
| Soporte en Node | Siempre | Desde Node 0.12 | Desde Node 7.6 |
| Cuándo usarlo | Eventos, streams, APIs antiguas | Composición puntual, Promise.all |
Todo lo demás |
La conclusión práctica para el resto del curso:
Escribe
async/awaitpor defecto. Usa.thencuando compongas promesas sin necesitar esperarlas (Promise.all,.catchen una llamada que no esperas). Usa callbacks cuando la API te obligue o cuando el resultado sea repetido en el tiempo — es decir, cuando estés ante un evento.
Errores Comunes y Consejos
Error 1: olvidar el return dentro de un .then.
La cadena no espera y el siguiente paso recibe undefined. Usa flechas sin llaves para evitarlo.
Error 2: await dentro de un for cuando las tareas son independientes.
Multiplicas el tiempo por el número de elementos. Promise.all con map.
Error 3: creer que await bloquea el proceso.
Solo pausa la función que lo contiene. El bucle de eventos sigue girando.
Error 4: llamar a una función async sin await ni .catch.
Rechazo no manejado, y desde Node 15 eso mata el proceso.
Error 5: Promise.all cuando lo correcto es allSettled.
Un fallo parcial cancela todo el lote y pierdes los resultados buenos.
Error 6: forEach con funciones async.
// NO espera a nada: forEach ignora las promesas que devuelve el callback.
ids.forEach(async (id) => { await procesar(id); });
console.log('Terminado'); // Mentira: no ha terminado nada
// Correcto:
await Promise.all(ids.map((id) => procesar(id)));Error 7: envolver una promesa en new Promise.
El constructor antipattern. Si ya es una promesa, devuélvela tal cual.
Error 8: reject('texto') en lugar de reject(new Error('texto')).
Pierdes la traza de pila y rompes la convención que todo el ecosistema espera.
Error 9: reintentar errores que no son transitorios. Reintentar cuatro veces un pago rechazado puede acabar en cargos duplicados.
Consejo 1: protege siempre tu función principal. principal().catch(...) en la última línea del fichero, sin excepción.
Consejo 2: pregúntate en cada await: "¿esto necesita el resultado del anterior?" Si la respuesta es no, hay una oportunidad de Promise.all.
Consejo 3: usa node:fs/promises y node:timers/promises directamente. No promisifiques lo que Node ya te da hecho.
Consejo 4: mantén los errores con codigo. La convención de la lección anterior sigue valiendo, y con promesas es todavía más útil, porque un solo catch recibe errores de pasos muy distintos y necesita distinguirlos.
Ejercicios
Ejercicio 1: convertir la capa de datos y medir la diferencia
Escribe src/laboratorio/catalogo-promesas.js que:
- Tome las funciones
buscarEvento,buscarSesionyreservarEntradasde la lección anterior y las convierta conutil.promisify. - Implemente
cargarCatalogoSerie(ids)conawaiten un buclefor. - Implemente
cargarCatalogoParalelo(ids)conPromise.all. - Ejecute las dos con los tres eventos, mida los tiempos con
Date.now()y muestre una tabla comparativa conconsole.tableque incluya el tiempo de cada una y el factor de mejora. - Añada una tercera variante
cargarCatalogoResiliente(ids)conPromise.allSettledque funcione incluso si se le pasaevt-999, informando de los fallos porstderr.
Responde por escrito: ¿qué factor de mejora obtienes con 3 eventos? ¿Y con 10? ¿Por qué el factor no crece indefinidamente en un caso real?
Ejercicio 2: la compra con tiempo límite y reintentos
Partiendo del flujo comprarEntradas del apartado 6, escribe src/laboratorio/compra-robusta.js que añada:
- Un tiempo límite de 2 segundos al cobro, usando
conLimiteDeTiempo. - Reintentos del cobro: hasta 3 intentos con espera exponencial desde 200 ms, solo para errores con código
TIEMPO_AGOTADO,ETIMEDOUToPASARELA_NO_DISPONIBLE. - Una pasarela simulada
cobrarPedido(pedido)que:- Rechace con
PAGO_RECHAZADOsi el total supera 20000 céntimos (no reintentable). - Falle con
PASARELA_NO_DISPONIBLElas dos primeras veces que se la llame y funcione a la tercera (reintentable).
- Rechace con
- Compensación correcta con
try/catch: si algo falla tras reservar el aforo, se libera. - Registro por
stderrde cada intento, y resultado final porstdout.
Comprueba los dos caminos: uno que acaba funcionando tras dos reintentos y uno que se rechaza sin reintentar.
Ejercicio 3: el panel de ocupación, en paralelo y por lotes
Escribe src/laboratorio/panel-ocupacion.js que genere el panel de ocupación de Escena Viva:
- Una función
consultarOcupacion(sesionId)que devuelva una promesa con{ sesionId, sala, porcentaje, recaudacionCentimos }tras una latencia de 30 ms. generarPanel(idsSesiones, tamanoLote)que use el ayudanteenLotesdel apartado 7 para consultar todas las sesiones con un máximo detamanoLoteen vuelo simultáneamente.- Agregación por sala: total de sesiones, ocupación media y recaudación en euros.
- Medición del retraso del bucle de eventos durante la generación, con
monitorEventLoopDelayde la lección anterior, para demostrar que la versión con promesas no bloquea aunque procese 300 sesiones. - Comparativa de tiempos con tamaños de lote 1, 10, 50 y 300.
Responde: ¿por qué el lote de 300 es el más rápido aquí y aun así no sería la mejor elección contra una base de datos real?
Soluciones
Solución 1
// src/laboratorio/catalogo-promesas.js
// Compara la carga secuencial, paralela y resiliente del catalogo.
const { promisify } = require('node:util');
// buscarEvento viene de la leccion anterior (callback error-first).
const buscarEventoP = promisify(buscarEvento);
// 2. En serie: cada peticion espera a la anterior.
async function cargarCatalogoSerie(ids) {
const eventos = [];
for (const id of ids) {
eventos.push(await buscarEventoP(id));
}
return eventos;
}
// 3. En paralelo: todas las peticiones arrancan a la vez.
async function cargarCatalogoParalelo(ids) {
return Promise.all(ids.map((id) => buscarEventoP(id)));
}
// 5. Resiliente: los fallos parciales no invalidan el resto.
async function cargarCatalogoResiliente(ids) {
const resultados = await Promise.allSettled(ids.map((id) => buscarEventoP(id)));
const eventos = [];
const fallos = [];
resultados.forEach((resultado, indice) => {
if (resultado.status === 'fulfilled') {
eventos.push(resultado.value);
} else {
fallos.push({ id: ids[indice], motivo: resultado.reason.message });
}
});
for (const fallo of fallos) {
console.error(`[catalogo] no se pudo cargar ${fallo.id}: ${fallo.motivo}`);
}
return { eventos, fallos };
}
// --- Medicion ---
async function medir(etiqueta, funcion, ids) {
const inicio = Date.now();
const resultado = await funcion(ids);
const ms = Date.now() - inicio;
const cuantos = Array.isArray(resultado) ? resultado.length : resultado.eventos.length;
return { etiqueta, eventos: cuantos, ms };
}
async function principal() {
const ids = ['evt-001', 'evt-002', 'evt-003'];
const serie = await medir('serie', cargarCatalogoSerie, ids);
const paralelo = await medir('paralelo', cargarCatalogoParalelo, ids);
console.table([
serie,
paralelo,
{ etiqueta: 'mejora', eventos: '-', ms: `x${(serie.ms / paralelo.ms).toFixed(1)}` }
]);
console.error('');
console.error('--- Variante resiliente con un id inexistente ---');
const resiliente = await cargarCatalogoResiliente([...ids, 'evt-999']);
console.log(
`Resiliente: ${resiliente.eventos.length} cargados, ${resiliente.fallos.length} fallidos`
);
}
principal().catch((error) => {
console.error(`Fallo fatal: ${error.message}`);
process.exitCode = 1;
});┌─────────┬────────────┬─────────┬──────┐ │ (index) │ etiqueta │ eventos │ ms │ ├─────────┼────────────┼─────────┼──────┤ │ 0 │ 'serie' │ 3 │ 124 │ │ 1 │ 'paralelo' │ 3 │ 42 │ │ 2 │ 'mejora' │ '-' │ 'x3.0' │ └─────────┴────────────┴─────────┴──────┘ --- Variante resiliente con un id inexistente --- [catalogo] no se pudo cargar evt-999: Evento no encontrado: evt-999 Resiliente: 3 cargados, 1 fallidos
Respuestas:
- Con 3 eventos: factor ~3. Con 10 eventos: factor ~10 (400 ms frente a 40 ms). En este laboratorio la mejora es lineal porque
setTimeoutno consume ningún recurso compartido. - En un caso real el factor no crece indefinidamente por tres motivos: el destino tiene un límite (una base de datos con 20 conexiones no atiende 500 consultas simultáneas más rápido que 20 a la vez), la red tiene un ancho de banda finito, y el propio Node tiene el thread pool de 4 hilos para las operaciones que pasan por él. A partir de cierto punto, más concurrencia solo añade cola. De ahí el patrón de paralelismo acotado por lotes.
Solución 2
// src/laboratorio/compra-robusta.js
// Flujo de compra con tiempo limite, reintentos selectivos y compensacion.
const { setTimeout: dormir } = require('node:timers/promises');
const CODIGOS_REINTENTABLES = new Set([
'TIEMPO_AGOTADO', 'ETIMEDOUT', 'PASARELA_NO_DISPONIBLE'
]);
// --- Utilidades ---
function conLimiteDeTiempo(promesa, limiteMs, mensaje = 'Tiempo de espera agotado') {
let temporizador;
const limite = new Promise((_, reject) => {
temporizador = setTimeout(() => {
const error = new Error(`${mensaje} (${limiteMs} ms)`);
error.codigo = 'TIEMPO_AGOTADO';
reject(error);
}, limiteMs);
});
return Promise.race([promesa, limite]).finally(() => clearTimeout(temporizador));
}
async function reintentar(operacion, { intentos = 3, esperaInicialMs = 200, factor = 2 } = {}) {
for (let intento = 1; intento <= intentos; intento++) {
try {
return await operacion(intento);
} catch (error) {
const reintentable = CODIGOS_REINTENTABLES.has(error.codigo);
if (!reintentable) {
console.error(`[reintento] "${error.codigo}" no es reintentable. Abandono.`);
throw error;
}
if (intento === intentos) {
console.error(`[reintento] agotados los ${intentos} intentos.`);
throw error;
}
const espera = esperaInicialMs * factor ** (intento - 1);
console.error(
`[reintento] intento ${intento}/${intentos} fallido (${error.message}). ` +
`Reintento en ${espera} ms.`
);
await dormir(espera);
}
}
}
// --- Pasarela simulada ---
let llamadasAPasarela = 0;
function cobrarPedido(pedido) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Error de negocio: NO reintentable.
if (pedido.totalCentimos > 20000) {
const error = new Error(
`Pago rechazado: ${(pedido.totalCentimos / 100).toFixed(2)} EUR supera el limite`
);
error.codigo = 'PAGO_RECHAZADO';
reject(error);
return;
}
// Error transitorio: las dos primeras llamadas fallan.
llamadasAPasarela++;
if (llamadasAPasarela <= 2) {
const error = new Error('La pasarela no esta disponible');
error.codigo = 'PASARELA_NO_DISPONIBLE';
reject(error);
return;
}
resolve({ ...pedido, estado: 'pagado' });
}, 40);
});
}
// --- Flujo de compra ---
async function comprarEntradas(usuarioId, eventoId, sesionId, cantidad) {
const evento = await buscarEventoP(eventoId);
await reservarEntradasP(sesionId, cantidad);
try {
const { sesion } = await buscarSesionP(sesionId);
const pedido = await crearPedidoP(usuarioId, sesion, cantidad);
// Reintentos por fuera, tiempo limite por dentro: cada intento
// tiene sus propios 2 segundos.
const pedidoPagado = await reintentar(
() => conLimiteDeTiempo(cobrarPedido(pedido), 2000, 'La pasarela no responde'),
{ intentos: 3, esperaInicialMs: 200 }
);
const entradas = await emitirEntradasP(pedidoPagado);
return { evento, sesion, pedido: pedidoPagado, entradas };
} catch (error) {
const libres = await liberarAforoP(sesionId, cantidad);
console.error(`[compensacion] aforo liberado en ${sesionId}, quedan ${libres} libres`);
throw error;
}
}
// --- Pruebas ---
async function principal() {
// Camino 1: 3 x 18,00 = 54,00 EUR. Falla dos veces y funciona a la tercera.
console.error('--- Compra que acaba funcionando tras dos reintentos ---');
const compra = await comprarEntradas('asis-001', 'evt-002', 'ses-002-2', 3);
console.log(`Pedido ${compra.pedido.id} - ${compra.pedido.estado}`);
console.log(` Importe: ${(compra.pedido.totalCentimos / 100).toFixed(2)} EUR`);
console.log(` Entradas: ${compra.entradas.map((e) => e.codigo).join(', ')}`);
// Camino 2: 5 x 42,00 = 210,00 EUR. Rechazo no reintentable.
console.error('');
console.error('--- Compra rechazada sin reintentos ---');
try {
await comprarEntradas('asis-002', 'evt-003', 'ses-003-2', 5);
} catch (error) {
console.error(`Resultado: [${error.codigo}] ${error.message}`);
}
}
principal().catch((error) => {
console.error(`Fallo fatal: ${error.message}`);
process.exitCode = 1;
});--- Compra que acaba funcionando tras dos reintentos --- [reintento] intento 1/3 fallido (La pasarela no esta disponible). Reintento en 200 ms. [reintento] intento 2/3 fallido (La pasarela no esta disponible). Reintento en 400 ms. Pedido ped-001 - pagado Importe: 54.00 EUR Entradas: EV-2026-000001, EV-2026-000002, EV-2026-000003 --- Compra rechazada sin reintentos --- [reintento] "PAGO_RECHAZADO" no es reintentable. Abandono. [compensacion] aforo liberado en ses-003-2, quedan 180 libres Resultado: [PAGO_RECHAZADO] Pago rechazado: 210.00 EUR supera el limite
Dos decisiones de diseño que conviene subrayar:
- El tiempo límite va dentro del reintento, no fuera. Así cada intento dispone de sus propios 2 segundos. Al revés, el límite global cortaría la cadena de reintentos a medias.
PAGO_RECHAZADOse abandona al primer intento. Es la línea que separa un reintento útil de un cobro duplicado.
Solución 3
// src/laboratorio/panel-ocupacion.js
// Panel de ocupacion de Escena Viva con paralelismo acotado y medicion del bucle.
const { monitorEventLoopDelay } = require('node:perf_hooks');
const SALAS = ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'];
const LATENCIA_MS = 30;
// 1. Consulta simulada de una sesion.
function consultarOcupacion(sesionId, indice) {
return new Promise((resolve) => {
setTimeout(() => {
const aforo = 420;
const vendidas = (indice * 37) % aforo;
resolve({
sesionId,
sala: SALAS[indice % SALAS.length],
porcentaje: Math.round((vendidas / aforo) * 100),
recaudacionCentimos: vendidas * 2500
});
}, LATENCIA_MS);
});
}
// 2. Paralelismo acotado.
async function enLotes(elementos, tamanoLote, tarea) {
const resultados = [];
for (let i = 0; i < elementos.length; i += tamanoLote) {
const lote = elementos.slice(i, i + tamanoLote);
resultados.push(...await Promise.all(lote.map(tarea)));
}
return resultados;
}
async function generarPanel(idsSesiones, tamanoLote) {
return enLotes(idsSesiones, tamanoLote, (id, i) => consultarOcupacion(id, i));
}
// 3. Agregacion por sala.
function agregarPorSala(ocupaciones) {
const porSala = new Map();
for (const o of ocupaciones) {
const a = porSala.get(o.sala) ?? { sala: o.sala, sesiones: 0, sumaPorcentajes: 0, recaudacionCentimos: 0 };
a.sesiones++;
a.sumaPorcentajes += o.porcentaje;
a.recaudacionCentimos += o.recaudacionCentimos;
porSala.set(o.sala, a);
}
return [...porSala.values()].map((a) => ({
sala: a.sala,
sesiones: a.sesiones,
ocupacionMedia: `${Math.round(a.sumaPorcentajes / a.sesiones)}%`,
recaudacionEuros: (a.recaudacionCentimos / 100).toFixed(2)
}));
}
async function principal() {
const ids = Array.from({ length: 300 }, (_, i) => `ses-${String(i + 1).padStart(3, '0')}-1`);
// 4. Medicion del retraso del bucle durante todo el proceso.
const histograma = monitorEventLoopDelay({ resolution: 5 });
histograma.enable();
// 5. Comparativa de tamanos de lote.
const comparativa = [];
for (const tamanoLote of [1, 10, 50, 300]) {
const inicio = Date.now();
await generarPanel(ids, tamanoLote);
comparativa.push({ tamanoLote, ms: Date.now() - inicio });
}
histograma.disable();
const ocupaciones = await generarPanel(ids, 50);
console.log('PANEL DE OCUPACION');
console.table(agregarPorSala(ocupaciones));
console.log('');
console.log('TIEMPO SEGUN EL TAMANO DE LOTE');
console.table(comparativa);
const aMs = (n) => (n / 1e6).toFixed(2);
console.error('');
console.error(`Retraso del bucle: media ${aMs(histograma.mean)} ms, p99 ${aMs(histograma.percentile(99))} ms`);
}
principal().catch((error) => {
console.error(`Fallo fatal: ${error.message}`);
process.exitCode = 1;
});PANEL DE OCUPACION ┌─────────┬───────────────────┬──────────┬────────────────┬──────────────────┐ │ (index) │ sala │ sesiones │ ocupacionMedia │ recaudacionEuros │ ├─────────┼───────────────────┼──────────┼────────────────┼──────────────────┤ │ 0 │ 'Teatro Almendra' │ 100 │ '49%' │ '515450.00' │ │ 1 │ 'Sala Boveda' │ 100 │ '50%' │ '523900.00' │ │ 2 │ 'Auditorio Ribera'│ 100 │ '50%' │ '521100.00' │ └─────────┴───────────────────┴──────────┴────────────────┴──────────────────┘ TIEMPO SEGUN EL TAMANO DE LOTE ┌─────────┬────────────┬──────┐ │ (index) │ tamanoLote │ ms │ ├─────────┼────────────┼──────┤ │ 0 │ 1 │ 9412 │ │ 1 │ 10 │ 942 │ │ 2 │ 50 │ 192 │ │ 3 │ 300 │ 32 │ └─────────┴────────────┴──────┘ Retraso del bucle: media 5.31 ms, p99 11.08 ms
Análisis:
- El lote de 300 es el más rápido (32 ms frente a 9,4 segundos) porque las 300 esperas transcurren simultáneamente: el coste total es el de una sola latencia de 30 ms.
- El retraso del bucle se mantiene en 5-11 ms incluso con 300 operaciones en vuelo. Compáralo con el informe síncrono de la lección anterior, que disparaba el p99 a 414 ms. Esta es la demostración de que la asincronía bien usada escala sin bloquear.
- Y sin embargo el lote de 300 no sería la elección correcta contra un sistema real: una base de datos con un grupo de 20 conexiones no ejecuta 300 consultas simultáneas, las encola; una API externa con límite de 100 peticiones por minuto te devolvería errores 429; y 300 respuestas llegando a la vez multiplican el uso de memoria. El tamaño de lote se elige por lo que aguanta el destino, no por lo que aguanta Node. Un valor entre 10 y 50 es el punto habitual, y se ajusta midiendo.
Conclusión
Has recuperado el lenguaje. Una promesa es un objeto con tres estados —pendiente, cumplida, rechazada—, que cambia de estado exactamente una vez y cuyo resultado es inmutable; esa sola propiedad elimina por construcción el bug de "el callback se llamó dos veces". La consumes con .then, .catch y .finally, y como cada uno devuelve una promesa nueva, los pasos se encadenan planos con un único punto de tratamiento de errores en lugar de la pirámide de la lección anterior.
Has aprendido a crearlas: new Promise(resolve, reject) para envolver lo que aún no es una promesa —un temporizador, un evento, una pasarela de pago— y, sobre todo, util.promisify para convertir de un plumazo las funciones error-first de Escena Viva que escribiste ayer. Y has visto el antipatrón que hay que evitar: nunca envolver una promesa dentro de otra.
Sobre esa base, async/await con sus tres reglas: una función async siempre devuelve una promesa, await pausa solo la función que lo contiene —lo demostraste con un latido que seguía sonando durante la espera— y try/catch/finally vuelve a funcionar. El flujo de compra completo pasó de 70 líneas con objeto contexto y dos rutas de error a 30 líneas con variables locales normales, un solo catch y una compensación de dos líneas.
Has interiorizado la distinción que separa el código lento del rápido: await en un bucle for sobre tareas independientes multiplica el tiempo por el número de elementos, mientras que Promise.all sobre un map lo deja en el coste de la más lenta. Y sabes elegir entre los cuatro combinadores: all cuando lo necesitas todo, allSettled cuando los fallos parciales son tolerables y hay que reportarlos, race para imponer un tiempo límite y any para fuentes redundantes. También sabes que el paralelismo se acota por lo que aguanta el destino, no por lo que aguanta Node.
Por último, tienes las herramientas de robustez que usarás siempre: dormir (o node:timers/promises), reintentos con espera exponencial y un predicado esReintentable que evita reintentar un pago rechazado, y tiempos límite con Promise.race recordando limpiar el temporizador en el finally. Y sabes que un rechazo no manejado mata el proceso desde Node 15, así que tu función principal siempre lleva su .catch.
Queda un caso que ni las promesas ni async/await cubren, y no por casualidad: una promesa se asienta una sola vez, pero hay resultados que ocurren muchas veces a lo largo del tiempo. Una sesión que se agota, una venta que se registra, un aforo que baja del 10 %, un socket que recibe datos: eso no es "un resultado futuro", es un flujo de sucesos. Para eso Node tiene un mecanismo propio que lleva en su ADN desde el primer día. En la siguiente lección, Eventos y EventEmitter, construirás el GestorDeVentas de Escena Viva y harás que el sistema entero se entere, sin acoplamiento alguno, en el instante en que una sesión se queda sin entradas.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
