Ya sabes cómo gira el bucle de eventos. Lo que aún no has visto es cómo se le entrega trabajo. La respuesta, en la Node.js clásica y todavía hoy en buena parte de su API, es el callback: una función que tú escribes y que Node guarda para llamarla más tarde, cuando el trabajo esté listo.

Esta lección enseña el patrón desde cero. No es historia antigua: aunque en el día a día escribas async/await, los callbacks siguen estando por debajo de todo —los usan los streams, los EventEmitter, el servidor HTTP y decenas de APIs de Node que nunca han recibido una versión con promesas—, y entender sus reglas es la única forma de entender qué resuelven exactamente las promesas de la lección siguiente.

Vas a aprender la forma canónica del callback en Node (el error-first callback), vas a escribir tus propias funciones asíncronas para Escena Viva, vas a descubrir por qué try/catch deja de funcionar en cuanto hay asincronía de por medio, y vas a construir con tus manos —a propósito— la temida pirámide de la muerte. Sentir esa incomodidad en carne propia es la mejor preparación posible para la lección siguiente.

Contenido

  1. Qué es un callback
  2. Síncrono frente a asíncrono: el mismo problema, dos formas
  3. El patrón canónico de Node: el error-first callback
  4. Escribir tus propias funciones asíncronas
  5. La regla de oro: exactamente una vez, y siempre asíncronamente
  6. Por qué try/catch no captura los errores asíncronos
  7. La pirámide de la muerte en Escena Viva
  8. Técnicas clásicas de mitigación
  9. Ventajas e inconvenientes de los callbacks
  10. Por qué los callbacks siguen importando

  1. Qué es un callback

Un callback (función de retorno) es simplemente una función que se pasa como argumento a otra función para que esta la llame en algún momento. No es un concepto de Node ni de asincronía: es una consecuencia de que en JavaScript las funciones son valores como los números o las cadenas.

De hecho llevas usando callbacks desde el Módulo 1 sin darles ese nombre:

// Callbacks SINCRONOS: map, filter y sort llaman a tu funcion
// inmediatamente, tantas veces como haga falta, y devuelven el resultado.

const titulos = catalogo.map((evento) => evento.titulo);
const grandes = catalogo.filter((evento) => evento.sala === 'Auditorio Ribera');
const ordenados = [...catalogo].sort((a, b) => a.titulo.localeCompare(b.titulo));

Estos son callbacks síncronos: cuando map devuelve, tu función ya se ha ejecutado todas las veces necesarias. No hay bucle de eventos involucrado.

Los interesantes para nosotros son los asíncronos:

// Callback ASINCRONO: se registra ahora y se ejecuta despues,
// cuando el bucle de eventos llegue a la fase correspondiente.

setTimeout(() => {
  console.log('Esto se ejecuta despues');
}, 1000);

console.log('Esto se ejecuta antes');

La diferencia es fundamental y define toda la lección:

Callback síncrono Callback asíncrono
Cuándo se ejecuta Antes de que la función que lo recibe devuelva Después, desde el bucle de eventos
Ejemplos map, filter, reduce, sort, forEach setTimeout, fs.readFile, servidor.on('request')
¿Se puede envolver en try/catch? Sí No (apartado 6)
¿Puede devolver un valor útil? Sí, al que lo llamó No: quien lo llamó ya devolvió hace rato

  1. Síncrono frente a asíncrono: el mismo problema, dos formas

Planteemos un problema concreto de Escena Viva: buscar un evento por su identificador. Hoy los datos están en memoria, pero en el Módulo 3 estarán en datos/eventos.json y en el Módulo 7 en una base de datos. Es decir: hoy es instantáneo, mañana implicará esperar.

Versión síncrona, la que sabrías escribir sin este curso:

// src/laboratorio/buscar-sincrono.js

const catalogo = [
  { id: 'evt-001', titulo: 'Concierto de Otono', sala: 'Teatro Almendra' },
  { id: 'evt-002', titulo: 'Noche de Monologos', sala: 'Sala Boveda' },
  { id: 'evt-003', titulo: 'Festival de Jazz de Primavera', sala: 'Auditorio Ribera' }
];

function buscarEventoSincrono(id) {
  const evento = catalogo.find((e) => e.id === id);
  if (!evento) {
    throw new Error(`Evento no encontrado: ${id}`);
  }
  return evento;
}

// Uso: natural, lineal, con try/catch que funciona.
try {
  const evento = buscarEventoSincrono('evt-002');
  console.log(`Encontrado: ${evento.titulo}`);
} catch (error) {
  console.error(`Error: ${error.message}`);
}

Todo encaja: el valor se devuelve, el error se lanza, y try/catch lo recoge. Es el modelo mental con el que aprendiste a programar.

Ahora la versión asíncrona, que es la que necesitarás en cuanto los datos vengan de disco o de red:

// src/laboratorio/buscar-asincrono.js

const catalogo = [
  { id: 'evt-001', titulo: 'Concierto de Otono', sala: 'Teatro Almendra' },
  { id: 'evt-002', titulo: 'Noche de Monologos', sala: 'Sala Boveda' },
  { id: 'evt-003', titulo: 'Festival de Jazz de Primavera', sala: 'Auditorio Ribera' }
];

// El resultado NO se devuelve: se entrega al callback.
// El error NO se lanza: se entrega al callback como primer argumento.
function buscarEvento(id, callback) {
  // setTimeout simula la latencia de un disco o de una base de datos.
  setTimeout(() => {
    const evento = catalogo.find((e) => e.id === id);
    if (!evento) {
      callback(new Error(`Evento no encontrado: ${id}`));
      return;
    }
    callback(null, evento);
  }, 50);
}

// Uso: el resultado llega "hacia dentro" de la funcion, no "hacia fuera".
buscarEvento('evt-002', (error, evento) => {
  if (error) {
    console.error(`Error: ${error.message}`);
    return;
  }
  console.log(`Encontrado: ${evento.titulo}`);
});

console.log('Esta linea se imprime ANTES que el resultado');
Esta linea se imprime ANTES que el resultado
Encontrado: Noche de Monologos

Compara las dos versiones con atención, porque el cambio de mentalidad es todo:

Aspecto Síncrono Asíncrono con callback
Entrega del resultado return evento callback(null, evento)
Entrega del error throw new Error(...) callback(new Error(...))
Flujo del código De arriba abajo Fragmentado: el "después" vive dentro del callback
Captura de errores try/catch Comprobar el primer argumento
Mientras espera El proceso está bloqueado El proceso atiende otras cosas

Esa última fila es la razón de ser de todo. Si buscarEvento tuviera que leer de disco, la versión síncrona congelaría el servidor de Escena Viva durante toda la lectura; la asíncrona deja el hilo libre para atender a otros compradores.

  1. El patrón canónico de Node: el error-first callback

Node no dejó la forma del callback a la imaginación de cada uno. Fijó una convención que sigue toda su biblioteca estándar y prácticamente todo el ecosistema:

El callback recibe el error como primer argumento y el resultado como segundo. Si no hubo error, el primer argumento es null.

function (err, resultado) { /* ... */ }

Se le llama error-first callback o Node-style callback. Así se ve en la API real:

const fs = require('node:fs');

fs.readFile('datos/eventos.json', 'utf8', (error, contenido) => {
  if (error) {
    console.error(`No se pudo leer el catalogo: ${error.message}`);
    return;
  }
  const catalogo = JSON.parse(contenido);
  console.log(`Cargados ${catalogo.length} eventos`);
});

Las reglas completas de la convención:

  1. El callback es siempre el último parámetro de la función.
  2. El primer argumento del callback es siempre el error, o null si todo fue bien.
  3. El error es un objeto Error, no una cadena. Un Error lleva message, stack y —en Node— a menudo un code ('ENOENT', 'EACCES') que permite decidir sin analizar el texto.
  4. Si hay error, no hay resultado. No se llama con las dos cosas a la vez.

Y el patrón de uso, que debes escribir en piloto automático:

funcionAsincrona(argumentos, (error, resultado) => {
  if (error) {
    // 1. Tratar el error
    // 2. RETORNAR. Este return es obligatorio.
    return;
  }
  // 3. Aqui, y solo aqui, el resultado es fiable
});

El return tras tratar el error no es opcional. Sin él, la ejecución continúa hacia el código del caso feliz con resultado valiendo undefined, y el fallo real queda enterrado bajo un TypeError: Cannot read properties of undefined. Es, sin exagerar, el error número uno de quien empieza con callbacks.

¿Por qué el error primero y no el resultado?

Porque obliga a verlo. El error ocupa la primera posición de la firma, así que aparece en cada callback que escribes aunque no quieras. Si estuviera al final, sería trivial declarar (resultado) => {...} y no enterarse jamás de que algo puede fallar.

Es una decisión de diseño deliberada: hacer el camino correcto el camino fácil.

  1. Escribir tus propias funciones asíncronas

Vamos a construir la capa de datos asíncrona de Escena Viva. Usaremos setTimeout para simular la latencia que en el Módulo 3 será real (disco) y en el Módulo 7 será de red (base de datos). Esto no es un truco didáctico gratuito: es exactamente lo que hace un doble de prueba en el Módulo 9.

Crea src/laboratorio/datos-asincronos.js:

// src/laboratorio/datos-asincronos.js
// Capa de acceso a datos de Escena Viva con callbacks al estilo de Node.
// La latencia se simula con setTimeout; en el modulo 3 sera E/S real.

const catalogo = [
  {
    id: 'evt-001',
    titulo: 'Concierto de Otono',
    sala: 'Teatro Almendra',
    organizador: 'org-almendra',
    sesiones: [
      { id: 'ses-001-1', fechaHora: '2026-10-03T20:00:00', aforo: 420, vendidas: 180, precioCentimos: 2500 },
      { id: 'ses-001-2', fechaHora: '2026-10-04T19:00:00', aforo: 420, vendidas: 96,  precioCentimos: 2200 }
    ]
  },
  {
    id: 'evt-002',
    titulo: 'Noche de Monologos',
    sala: 'Sala Boveda',
    organizador: 'org-boveda',
    sesiones: [
      { id: 'ses-002-1', fechaHora: '2026-10-10T21:30:00', aforo: 120, vendidas: 118, precioCentimos: 1800 },
      { id: 'ses-002-2', fechaHora: '2026-10-11T21:30:00', aforo: 120, vendidas: 45,  precioCentimos: 1800 },
      { id: 'ses-002-3', fechaHora: '2026-10-17T21:30:00', aforo: 120, vendidas: 12,  precioCentimos: 1500 }
    ]
  },
  {
    id: 'evt-003',
    titulo: 'Festival de Jazz de Primavera',
    sala: 'Auditorio Ribera',
    organizador: 'org-ribera',
    sesiones: [
      { id: 'ses-003-1', fechaHora: '2027-04-17T19:00:00', aforo: 900, vendidas: 640, precioCentimos: 3800 },
      { id: 'ses-003-2', fechaHora: '2027-04-18T19:00:00', aforo: 900, vendidas: 720, precioCentimos: 4200 }
    ]
  }
];

// Latencia simulada, en milisegundos.
const LATENCIA_MS = 40;

// --- Busqueda de un evento por su id ---
function buscarEvento(id, callback) {
  setTimeout(() => {
    const evento = catalogo.find((e) => e.id === id);

    if (!evento) {
      // Error con codigo, igual que hace Node: permite decidir sin leer el texto.
      const error = new Error(`Evento no encontrado: ${id}`);
      error.codigo = 'EVENTO_NO_ENCONTRADO';
      callback(error);
      return;
    }

    callback(null, evento);
  }, LATENCIA_MS);
}

// --- Busqueda de una sesion por su id, en todo el catalogo ---
function buscarSesion(sesionId, callback) {
  setTimeout(() => {
    for (const evento of catalogo) {
      const sesion = evento.sesiones.find((s) => s.id === sesionId);
      if (sesion) {
        // Devolvemos tambien el evento: quien llama casi siempre lo necesita.
        callback(null, { evento, sesion });
        return;
      }
    }

    const error = new Error(`Sesion no encontrada: ${sesionId}`);
    error.codigo = 'SESION_NO_ENCONTRADA';
    callback(error);
  }, LATENCIA_MS);
}

// --- Reserva de entradas ---
function reservarEntradas(sesionId, cantidad, callback) {
  // Validacion de argumentos ANTES de cualquier trabajo.
  if (!Number.isInteger(cantidad) || cantidad < 1) {
    const error = new Error('La cantidad debe ser un entero positivo');
    error.codigo = 'CANTIDAD_INVALIDA';
    // Ojo: aqui hay una trampa. La resolvemos en el apartado 5.
    callback(error);
    return;
  }

  buscarSesion(sesionId, (error, resultado) => {
    if (error) {
      callback(error);
      return;
    }

    const { evento, sesion } = resultado;
    const libres = sesion.aforo - sesion.vendidas;

    if (cantidad > libres) {
      const fallo = new Error(
        `Aforo insuficiente en ${sesionId}: pides ${cantidad} y quedan ${libres}`
      );
      fallo.codigo = 'AFORO_INSUFICIENTE';
      callback(fallo);
      return;
    }

    // Como el JavaScript del usuario es monohilo, esta lectura-modificacion-escritura
    // es atomica: nadie puede colarse entre las dos lineas.
    sesion.vendidas += cantidad;

    callback(null, {
      eventoId: evento.id,
      sesionId: sesion.id,
      cantidad,
      importeCentimos: cantidad * sesion.precioCentimos,
      libresRestantes: sesion.aforo - sesion.vendidas
    });
  }, LATENCIA_MS);
}

Pruébalo:

// Caso feliz
reservarEntradas('ses-002-2', 3, (error, reserva) => {
  if (error) {
    console.error(`No se pudo reservar: ${error.message}`);
    return;
  }
  console.log(
    `Reservadas ${reserva.cantidad} entradas de ${reserva.sesionId} ` +
    `por ${(reserva.importeCentimos / 100).toFixed(2)} EUR. ` +
    `Quedan ${reserva.libresRestantes}.`
  );
});

// Caso de aforo insuficiente: ses-002-1 tiene 118 de 120 vendidas
reservarEntradas('ses-002-1', 5, (error) => {
  if (error) {
    console.error(`[${error.codigo}] ${error.message}`);
  }
});
Reservadas 3 entradas de ses-002-2 por 54.00 EUR. Quedan 72.
[AFORO_INSUFICIENTE] Aforo insuficiente en ses-002-1: pides 5 y quedan 2

Fíjate en dos detalles de diseño que se repetirán en todo el curso:

  • error.codigo además de error.message. El mensaje es para las personas; el código es para el programa. En el Módulo 6 ese código se traducirá a un estado HTTP: SESION_NO_ENCONTRADA → 404, AFORO_INSUFICIENTE → 409.
  • buscarSesion devuelve { evento, sesion }. Devolver la información que quien llama va a necesitar de todos modos evita una segunda consulta.

  1. La regla de oro: exactamente una vez, y siempre asíncronamente

Escribir funciones que reciben callbacks es fácil. Escribir funciones que aceptan callbacks correctamente tiene dos reglas que no admiten excepción.

Regla 1: llama al callback exactamente una vez

Ni cero veces (quien te llamó espera para siempre) ni dos (el código de después se ejecuta duplicado). Esta es la causa del error clásico que ya mencionamos:

// MAL: sin return, el callback se llama DOS veces cuando hay error.
function buscarEventoMal(id, callback) {
  setTimeout(() => {
    const evento = catalogo.find((e) => e.id === id);
    if (!evento) {
      callback(new Error('No encontrado'));   // Falta el return
    }
    callback(null, evento);                   // Se ejecuta igualmente
  }, 40);
}

buscarEventoMal('evt-999', (error, evento) => {
  if (error) {
    console.error('Error tratado');
    return;
  }
  console.log(evento.titulo);   // TypeError: Cannot read properties of undefined
});
Error tratado
TypeError: Cannot read properties of undefined (reading 'titulo')

El error se trató correctamente… y aun así el proceso falló, porque el callback se invocó una segunda vez con undefined. Un return tras cada llamada al callback. Sin excepciones.

Regla 2: llama al callback siempre de forma asíncrona

Esta es más sutil y mucho más traicionera. Mira otra vez la validación de reservarEntradas:

function reservarEntradas(sesionId, cantidad, callback) {
  if (!Number.isInteger(cantidad) || cantidad < 1) {
    callback(error);   // <-- Se llama SINCRONAMENTE
    return;
  }
  buscarSesion(sesionId, (...) => {
    callback(null, resultado);   // <-- Se llama ASINCRONAMENTE
  });
}

Tenemos una función a veces síncrona y a veces asíncrona. Y eso rompe cosas:

// src/laboratorio/zalgo.js
// Demuestra el peligro de una funcion "a veces sincrona".

let estado = 'sin inicializar';

reservarEntradas('ses-002-2', -1, (error) => {
  console.log(`  Dentro del callback, estado = "${estado}"`);
});

estado = 'inicializado';
console.log(`Despues de la llamada, estado = "${estado}"`);
  Dentro del callback, estado = "sin inicializar"
Despues de la llamada, estado = "inicializado"

El callback se ejecutó antes de que la línea siguiente hubiera corrido. Con una cantidad válida, en cambio, el orden habría sido el contrario. La misma función produce dos órdenes de ejecución distintos según sus argumentos.

Este problema tiene nombre propio en la comunidad de Node —"no liberes a Zalgo", por un artículo clásico de Isaac Schlueter— y produce errores de los peores: intermitentes, dependientes de los datos, imposibles de reproducir.

El remedio es process.nextTick, y este es exactamente uno de los dos casos legítimos que anunciamos en la lección del bucle de eventos:

// BIEN: el callback SIEMPRE se invoca de forma asincrona.
function reservarEntradas(sesionId, cantidad, callback) {
  if (!Number.isInteger(cantidad) || cantidad < 1) {
    const error = new Error('La cantidad debe ser un entero positivo');
    error.codigo = 'CANTIDAD_INVALIDA';

    // Aplazamos la llamada para garantizar asincronia uniforme.
    process.nextTick(() => callback(error));
    return;
  }

  // ... resto igual
}

Ahora la salida es siempre la misma, con cualquier argumento:

Despues de la llamada, estado = "inicializado"
  Dentro del callback, estado = "inicializado"
Situación Qué usar
Ruta de error inmediata (validación de argumentos) process.nextTick(() => callback(error))
Resultado que ya tienes en memoria o en caché process.nextTick(() => callback(null, valor))
Trabajo largo que hay que trocear setImmediate

Nota profesional. La biblioteca estándar de Node cumple esta regla escrupulosamente. fs.readFile con una ruta inexistente no te llama de vuelta de forma síncrona: aplaza el error. Cuando escribas una API asíncrona propia, cúmplela tú también. Es la diferencia entre una biblioteca en la que se puede confiar y una que produce sustos.

  1. Por qué try/catch no captura los errores asíncronos

Este es el motivo profundo de que exista la convención error-first. Vamos a demostrarlo.

// src/laboratorio/try-catch-roto.js
// El try/catch NO captura lo que se lanza dentro de un callback asincrono.

function operacionQueFalla(callback) {
  setTimeout(() => {
    throw new Error('Fallo dentro del callback asincrono');
  }, 50);
}

try {
  operacionQueFalla();
  console.log('El try ha terminado sin capturar nada');
} catch (error) {
  console.error('Esto NUNCA se imprime:', error.message);
}

console.log('El script continua...');
El try ha terminado sin capturar nada
El script continua...

/ruta/src/laboratorio/try-catch-roto.js:6
    throw new Error('Fallo dentro del callback asincrono');
    ^
Error: Fallo dentro del callback asincrono
    ...
[el proceso muere con codigo 1]

¿Por qué? Porque el bloque try ya había terminado cuando el error se lanzó. Recuerda el bucle de eventos:

sequenceDiagram
    participant P as Pila de llamadas
    participant B as Bucle de eventos
    P->>P: entra en el bloque try
    P->>B: setTimeout programa el callback
    P->>P: sale del bloque try (¡ya terminó!)
    P->>P: la pila se vacía
    Note over B: pasan 50 ms
    B->>P: ejecuta el callback (pila NUEVA)
    P--xP: throw sin ningún try alrededor
    Note over P: excepción no capturada → el proceso muere

try/catch protege una región de la pila de llamadas, y el callback se ejecuta en una pila completamente nueva, creada por el bucle de eventos mucho después. No hay ninguna relación entre ambas.

La consecuencia práctica, que hay que interiorizar:

Dentro de una función asíncrona, nunca lances un error hacia fuera. Pásalo al callback.

// MAL: nadie puede capturar esto.
function reservarMal(sesionId, cantidad, callback) {
  setTimeout(() => {
    if (cantidad > 10) throw new Error('Maximo 10 entradas por pedido');
    callback(null, { sesionId, cantidad });
  }, 40);
}

// BIEN: el error viaja por el canal previsto.
function reservarBien(sesionId, cantidad, callback) {
  setTimeout(() => {
    if (cantidad > 10) {
      callback(new Error('Maximo 10 entradas por pedido'));
      return;
    }
    callback(null, { sesionId, cantidad });
  }, 40);
}

Y una variante importante: sí puedes usar try/catch dentro del callback, porque ahí sí estás en la misma pila.

fs.readFile('datos/eventos.json', 'utf8', (error, contenido) => {
  if (error) {
    console.error(`Error de lectura: ${error.message}`);
    return;
  }

  // JSON.parse es SINCRONO: aqui try/catch si funciona.
  let catalogo;
  try {
    catalogo = JSON.parse(contenido);
  } catch (fallo) {
    console.error(`El catalogo no es JSON valido: ${fallo.message}`);
    return;
  }

  console.log(`Cargados ${catalogo.length} eventos`);
});

Como red de seguridad de último recurso existe process.on('uncaughtException'), pero no es un mecanismo de manejo de errores: cuando salta, el estado de tu aplicación es desconocido. Solo sirve para registrar el fallo y cerrar de forma ordenada. Lo veremos en el Módulo 11.

  1. La pirámide de la muerte en Escena Viva

Ahora el problema real. El flujo de compra completo de Escena Viva tiene cuatro pasos encadenados, y cada uno depende del anterior:

flowchart LR
    A["1. Buscar el evento"] --> B["2. Comprobar el aforo<br/>de la sesion"]
    B --> C["3. Crear el pedido<br/>estado: pendiente"]
    C --> D["4. Emitir las entradas<br/>y pasar a emitido"]

Con callbacks, "depende del anterior" significa "va dentro del anterior". Y el resultado es esto:

// src/laboratorio/compra-piramide.js
// El flujo de compra completo. Funciona, y es una pesadilla de mantener.

comprarEntradas('asis-001', 'evt-002', 'ses-002-2', 3);

function comprarEntradas(usuarioId, eventoId, sesionId, cantidad) {
  buscarEvento(eventoId, (error, evento) => {
    if (error) {
      console.error(`[compra] no se encontro el evento: ${error.message}`);
      return;
    }

    comprobarAforo(sesionId, cantidad, (error, sesion) => {
      if (error) {
        console.error(`[compra] aforo: ${error.message}`);
        return;
      }

      crearPedido(usuarioId, sesion, cantidad, (error, pedido) => {
        if (error) {
          console.error(`[compra] no se pudo crear el pedido: ${error.message}`);
          return;
        }

        cobrarPedido(pedido, (error, pedidoPagado) => {
          if (error) {
            // Y ademas hay que DESHACER: liberar el aforo reservado.
            liberarAforo(sesionId, cantidad, (errorLiberar) => {
              if (errorLiberar) {
                console.error(`[compra] fallo al liberar aforo: ${errorLiberar.message}`);
              }
              console.error(`[compra] cobro rechazado: ${error.message}`);
            });
            return;
          }

          emitirEntradas(pedidoPagado, (error, entradas) => {
            if (error) {
              console.error(`[compra] no se pudieron emitir las entradas: ${error.message}`);
              return;
            }

            console.log(`Pedido ${pedidoPagado.id} completado`);
            console.log(`Evento : ${evento.titulo}`);
            console.log(`Importe: ${(pedidoPagado.totalCentimos / 100).toFixed(2)} EUR`);
            for (const entrada of entradas) {
              console.log(`  ${entrada.codigo}  ${entrada.estado}`);
            }
          });
        });
      });
    });
  });
}

A esto se le llama pirámide de la muerte (pyramid of doom) o infierno de callbacks (callback hell), y no es una cuestión estética. Los problemas son concretos y medibles:

Problema Por qué duele
Indentación creciente Con seis niveles, el código útil empieza en la columna 30. En pantallas normales no cabe
Manejo de errores repetido El mismo if (error) { ...; return; } cinco veces, y cada uno hay que escribirlo a mano
Deshacer es un infierno Mira el liberarAforo anidado dentro del error del cobro: si hubiera tres cosas que deshacer, serían tres niveles más
Imposible de leer en orden Para saber qué pasa después del paso 3 hay que bajar, no seguir leyendo
Difícil de reutilizar Ninguno de esos pasos se puede extraer sin reescribirlo todo
Imposible paralelizar Si los pasos 1 y 2 fueran independientes, con esta estructura seguirían ejecutándose en serie
Variables atrapadas en el cierre evento solo está disponible dentro de su nivel; para usarlo abajo hay que arrastrarlo por toda la pirámide

Y falta lo peor: este ejemplo es corto. Un flujo real añade validación de usuario, comprobación de descuentos, registro de auditoría y envío de correo de confirmación. Diez niveles no es una exageración.

  1. Técnicas clásicas de mitigación

Antes de las promesas, la comunidad desarrolló técnicas para hacer esto habitable. Siguen siendo válidas y buenas prácticas de diseño en sí mismas.

8.1 Funciones con nombre en lugar de anónimas

En vez de anidar funciones anónimas, se declara cada paso por separado y se pasa por referencia:

// src/laboratorio/compra-plana.js
// Mismo flujo, aplanado con funciones con nombre.

function comprarEntradas(usuarioId, eventoId, sesionId, cantidad) {
  // Contexto compartido por todos los pasos: sustituye a los cierres anidados.
  const contexto = { usuarioId, eventoId, sesionId, cantidad };

  buscarEvento(eventoId, alEncontrarEvento);

  function alEncontrarEvento(error, evento) {
    if (error) return fallo('evento', error);
    contexto.evento = evento;
    comprobarAforo(sesionId, cantidad, alComprobarAforo);
  }

  function alComprobarAforo(error, sesion) {
    if (error) return fallo('aforo', error);
    contexto.sesion = sesion;
    crearPedido(usuarioId, sesion, cantidad, alCrearPedido);
  }

  function alCrearPedido(error, pedido) {
    if (error) return fallo('pedido', error);
    contexto.pedido = pedido;
    cobrarPedido(pedido, alCobrar);
  }

  function alCobrar(error, pedidoPagado) {
    if (error) return compensarYFallar(error);
    contexto.pedido = pedidoPagado;
    emitirEntradas(pedidoPagado, alEmitir);
  }

  function alEmitir(error, entradas) {
    if (error) return fallo('emision', error);
    mostrarResumen(contexto, entradas);
  }

  // Un unico punto de tratamiento de errores para todo el flujo.
  function fallo(paso, error) {
    console.error(`[compra] fallo en el paso "${paso}": ${error.message}`);
  }

  function compensarYFallar(error) {
    liberarAforo(sesionId, cantidad, (errorLiberar) => {
      if (errorLiberar) {
        console.error(`[compra] CRITICO: aforo no liberado: ${errorLiberar.message}`);
      }
      fallo('cobro', error);
    });
  }
}

Ganancias inmediatas:

  • Indentación constante. Ningún nivel supera dos.
  • El flujo se lee de arriba abajo, en el mismo orden en que ocurre.
  • Un único punto de tratamiento de errores (fallo), no cinco copias.
  • Cada paso es una función con nombre, lo que significa que aparece con su nombre en las trazas de error y se puede probar por separado (Módulo 9).

El precio es el objeto contexto: al perder los cierres anidados hay que llevar el estado a mano. Es un intercambio razonable.

8.2 Retorno temprano

Ya lo hemos usado, pero merece enunciarse como técnica: trata el error y sal, en lugar de envolver el caso feliz en un else.

// Peor: el caso feliz queda indentado y el else se acumula.
buscarEvento(id, (error, evento) => {
  if (error) {
    console.error(error.message);
  } else {
    console.log(evento.titulo);
  }
});

// Mejor: el caso feliz queda al nivel principal.
buscarEvento(id, (error, evento) => {
  if (error) return console.error(error.message);
  console.log(evento.titulo);
});

8.3 Modularizar

Si una función tiene más de dos niveles de callback, casi siempre está haciendo más de una cosa. En el flujo de compra, crearPedido + cobrarPedido + compensar es una unidad conceptual (procesar el pago) que puede vivir en su propio fichero y exponer un solo callback.

En la lección Módulos CommonJS y require() veremos cómo hacer esa separación de verdad. La idea de fondo: la profundidad de la pirámide suele ser un síntoma de mala separación de responsabilidades, no solo de la sintaxis de los callbacks.

8.4 Un ayudante para ejecutar pasos en serie

Cuando los pasos tienen la misma forma, se puede escribir un pequeño motor:

// src/utiles/en-serie.js
// Ejecuta una lista de pasos en orden, pasando el contexto de uno a otro.
// Cada paso tiene la firma: (contexto, siguiente) => void
// donde siguiente es un error-first callback.

function enSerie(pasos, contexto, alTerminar) {
  let indice = 0;

  function siguiente(error) {
    if (error) {
      alTerminar(error, contexto);
      return;
    }
    if (indice >= pasos.length) {
      alTerminar(null, contexto);
      return;
    }

    const paso = pasos[indice++];

    // Aplazamos para garantizar asincronia uniforme (regla de oro 2).
    process.nextTick(() => paso(contexto, siguiente));
  }

  siguiente();
}

module.exports = { enSerie };

Uso:

enSerie(
  [
    (ctx, siguiente) => buscarEvento(ctx.eventoId, (e, evento) => {
      ctx.evento = evento;
      siguiente(e);
    }),
    (ctx, siguiente) => comprobarAforo(ctx.sesionId, ctx.cantidad, (e, sesion) => {
      ctx.sesion = sesion;
      siguiente(e);
    }),
    (ctx, siguiente) => crearPedido(ctx.usuarioId, ctx.sesion, ctx.cantidad, (e, pedido) => {
      ctx.pedido = pedido;
      siguiente(e);
    })
  ],
  { usuarioId: 'asis-001', eventoId: 'evt-002', sesionId: 'ses-002-2', cantidad: 3 },
  (error, contexto) => {
    if (error) return console.error(`[compra] ${error.message}`);
    console.log(`Pedido ${contexto.pedido.id} creado para ${contexto.evento.titulo}`);
  }
);

Esto es, en esencia, lo que hacía la biblioteca async, omnipresente en el ecosistema Node entre 2011 y 2016. Si te encuentras async.series, async.waterfall o async.parallel en código heredado, ya sabes qué son.

Y si te parece que sigue siendo mucha maquinaria para algo que debería ser sencillo… tienes toda la razón. Esa es exactamente la conclusión a la que llegó la comunidad, y por eso llegaron las promesas.

  1. Ventajas e inconvenientes de los callbacks

Ventajas Inconvenientes
Simplicidad conceptual: es solo una función que se pasa como argumento Anidamiento: los flujos secuenciales crecen en profundidad
Sin coste de rendimiento: no hay objeto intermedio ni cola de microtareas Manejo de errores repetitivo: if (error) return en cada nivel
Universales: los soporta cualquier versión de Node y del navegador try/catch no funciona: el modelo de errores del lenguaje no aplica
Perfectos para eventos repetidos: un callback puede llamarse muchas veces Fáciles de usar mal: llamar dos veces, no llamar, llamar síncronamente
Necesarios para streams y EventEmitter Componer es difícil: encadenar, paralelizar o cancelar requiere ayudantes
Menor consumo de memoria en operaciones muy frecuentes Inversión de control: entregas tu función a un tercero y confías en que la llame bien

Ese último inconveniente merece una nota. Cuando pasas un callback a una biblioteca, estás confiando en que la llame una vez, con los argumentos correctos y de forma asíncrona. Si la biblioteca tiene un fallo, tú sufres las consecuencias y depurarlo es dificilísimo. Las promesas eliminan este problema de raíz, porque el contrato lo impone el lenguaje y no cada autor.

  1. Por qué los callbacks siguen importando

Con async/await disponible desde Node 7.6, ¿por qué dedicar una lección entera a los callbacks? Cuatro razones muy prácticas:

1. Hay APIs de Node que solo aceptan callbacks. No todo tiene versión con promesas. fs.watch, dns.lookup, gran parte de crypto, muchas opciones de child_process y —sobre todo— todo lo basado en eventos siguen siendo territorio de callbacks.

2. EventEmitter es puramente de callbacks. Cuando escribas emisor.on('sesion-agotada', (datos) => {...}), eso es un callback. Y no puede ser una promesa, porque una promesa se resuelve una sola vez y un evento se emite muchas. Es el tema de la lección Eventos y EventEmitter.

3. Los streams funcionan con callbacks y eventos. Todo el Módulo 3 se apoya en stream.on('data', ...), stream.on('end', ...) y en callbacks de finalización.

4. El middleware de Express es un callback. La firma (req, res, next) del Módulo 6 es exactamente el patrón que acabas de aprender, con next haciendo el papel de "continúa con el siguiente paso".

Dicho de otro modo: las promesas sustituyen a los callbacks para la asincronía de un solo resultado, no para todo lo demás. Saber cuándo usar cada uno es parte de escribir Node profesionalmente, y a eso dedicaremos una tabla de decisión en la lección de EventEmitter.

Errores Comunes y Consejos

Error 1: olvidar el return después de tratar el error. El callback se llama dos veces y el fallo real queda enterrado bajo un TypeError. Es el error número uno.

Error 2: lanzar excepciones dentro de un callback asíncrono. Nadie las captura; el proceso muere. Pásalas siempre por el primer argumento del callback.

Error 3: envolver una llamada asíncrona en try/catch y creer que estás protegido. El try ya ha terminado cuando el callback se ejecuta.

Error 4: escribir funciones "a veces síncronas". El orden de ejecución cambia según los datos y aparecen errores irreproducibles. process.nextTick en la ruta rápida.

Error 5: intentar devolver un valor desde un callback.

// Esto NO funciona: buscarEvento devuelve undefined mucho antes.
function obtenerTitulo(id) {
  let titulo;
  buscarEvento(id, (error, evento) => { titulo = evento.titulo; });
  return titulo;   // undefined, siempre
}

No hay forma de convertir asíncrono en síncrono. La única salida es propagar la asincronía hacia arriba.

Error 6: usar callbacks dentro de forEach esperando que se respete el orden. forEach no espera a nada. Los callbacks terminan en orden aleatorio y no hay forma de saber cuándo acabaron todos.

Consejo 1: escribe siempre (error, resultado), no (err, res). En un manejador de Express, res significa otra cosa muy distinta, y la confusión es real.

Consejo 2: pon un codigo a tus errores. El mensaje es para las personas; el código es lo que tu código usa para decidir.

Consejo 3: si llevas tres niveles de anidamiento, para y extrae funciones. No sigas escribiendo hacia la derecha.

Consejo 4: no reescribas código de callbacks que funciona solo por moda. Aprende a convertirlo cuando toque —con util.promisify, en la lección siguiente— pero un fs.watch con su callback no necesita ninguna mejora.

Ejercicios

Ejercicio 1: arreglar una función asíncrona rota

Esta función tiene cuatro defectos según lo aprendido en la lección. Encuéntralos todos, explica qué consecuencia tiene cada uno y reescríbela correctamente.

function obtenerOcupacion(sesionId, callback) {
  if (!sesionId) {
    callback('Falta el identificador de sesion');
  }

  setTimeout(() => {
    const sesion = buscarSesionEnMemoria(sesionId);

    if (!sesion) {
      callback(new Error('No encontrada'));
    }

    try {
      const porcentaje = Math.round((sesion.vendidas / sesion.aforo) * 100);
      callback(null, porcentaje);
    } catch (error) {
      throw error;
    }
  }, 30);
}

Ejercicio 2: el informe de sesiones en riesgo, en asíncrono

En la lección Tu Primer Programa en Node.js escribiste un informe síncrono de sesiones con menos del 20 % vendido. Reescríbelo con la capa asíncrona de esta lección.

Escribe src/laboratorio/informe-riesgo.js con:

  1. Una función listarSesionesEnRiesgo(umbralPorcentaje, callback) que use buscarEvento para cargar los tres eventos uno a uno, en serie (los ids son evt-001, evt-002 y evt-003) y devuelva por callback un array de objetos { eventoId, titulo, sesionId, fechaHora, porcentaje }.
  2. Manejo de errores correcto: si falla cualquier evento, el callback debe recibir el error y no debe llamarse más veces.
  3. Salida por stdout con console.table y process.exitCode = 1 si hay alguna sesión en riesgo.
  4. El umbral debe poder pasarse por línea de comandos: node src/laboratorio/informe-riesgo.js 25.

Al terminar, responde: ¿cuánto tarda tu solución con una latencia de 40 ms por evento? ¿Cuánto tardaría si los tres eventos se cargaran a la vez? ¿Por qué con esta estructura no puedes hacerlo?

Ejercicio 3: aplanar la pirámide de compra

Toma el flujo de comprarEntradas del apartado 7 e impleméntalo de forma completa y ejecutable, con estas piezas simuladas (todas con latencia de 30 ms y firma error-first):

  • crearPedido(usuarioId, sesion, cantidad, callback) → devuelve { id: 'ped-001', usuarioId, sesionId, cantidad, totalCentimos, estado: 'pendiente' }.
  • cobrarPedido(pedido, callback) → falla con código PAGO_RECHAZADO si totalCentimos > 20000; si no, devuelve el pedido con estado: 'pagado'.
  • emitirEntradas(pedido, callback) → devuelve un array de cantidad entradas { codigo: 'EV-2026-000001', sesionId, estado: 'valida' } y deja el pedido en estado: 'emitido'.
  • liberarAforo(sesionId, cantidad, callback) → devuelve el número de libres tras liberar.

Requisitos:

  1. Estructura plana, con funciones con nombre y un objeto contexto (técnica 8.1).
  2. Un único punto de tratamiento de errores.
  3. Compensación correcta: si el cobro falla, se libera el aforo antes de informar.
  4. Prueba los dos caminos: ses-002-2 con 3 entradas (54,00 EUR, debe funcionar) y ses-003-2 con 5 entradas (210,00 EUR, debe rechazarse y liberar el aforo).

Soluciones

Solución 1

Los cuatro defectos:

# Defecto Consecuencia
1 Falta el return tras callback('Falta el identificador...') La ejecución continúa al setTimeout y el callback se llama dos veces
2 El error es una cadena, no un objeto Error Quien lo recibe no tiene stack ni code; además rompe la convención
3 Llamada síncrona en la ruta de validación Función "a veces síncrona": Zalgo. El orden de ejecución cambia según los argumentos
4 Falta el return tras callback(new Error('No encontrada')) Se ejecuta el try con sesion valiendo undefined: TypeError

Y un quinto defecto de bonus: el try/catch que hace throw error es inútil y dañino. Captura la excepción para volver a lanzarla dentro de un callback asíncrono, donde nadie puede recogerla y donde tumbará el proceso.

Versión corregida:

// src/laboratorio/obtener-ocupacion.js
// Devuelve el porcentaje de ocupacion de una sesion.

function obtenerOcupacion(sesionId, callback) {
  // 1. Validacion de argumentos, aplazada para garantizar asincronia uniforme.
  if (!sesionId) {
    const error = new Error('Falta el identificador de sesion');
    error.codigo = 'ARGUMENTO_INVALIDO';
    process.nextTick(() => callback(error));
    return;
  }

  setTimeout(() => {
    const sesion = buscarSesionEnMemoria(sesionId);

    // 2. Error con return: el callback se llama exactamente una vez.
    if (!sesion) {
      const error = new Error(`Sesion no encontrada: ${sesionId}`);
      error.codigo = 'SESION_NO_ENCONTRADA';
      callback(error);
      return;
    }

    // 3. Proteccion real ante datos incoherentes, en lugar de un try/catch inutil.
    if (!sesion.aforo || sesion.aforo <= 0) {
      const error = new Error(`Aforo invalido en ${sesionId}: ${sesion.aforo}`);
      error.codigo = 'DATO_INCOHERENTE';
      callback(error);
      return;
    }

    const porcentaje = Math.round((sesion.vendidas / sesion.aforo) * 100);
    callback(null, porcentaje);
  }, 30);
}

Solución 2

// src/laboratorio/informe-riesgo.js
// Sesiones con menos del umbral de ocupacion, cargando los eventos en serie.

const EVENTOS = ['evt-001', 'evt-002', 'evt-003'];
const UMBRAL_POR_DEFECTO = 20;

function listarSesionesEnRiesgo(umbralPorcentaje, callback) {
  const enRiesgo = [];
  let indice = 0;
  let terminado = false;   // Guarda contra llamadas multiples al callback.

  function siguienteEvento() {
    if (indice >= EVENTOS.length) {
      finalizar(null, enRiesgo);
      return;
    }

    const eventoId = EVENTOS[indice++];

    buscarEvento(eventoId, (error, evento) => {
      if (error) {
        finalizar(error);
        return;
      }

      for (const sesion of evento.sesiones) {
        const porcentaje = Math.round((sesion.vendidas / sesion.aforo) * 100);
        if (porcentaje < umbralPorcentaje) {
          enRiesgo.push({
            eventoId: evento.id,
            titulo: evento.titulo,
            sesionId: sesion.id,
            fechaHora: sesion.fechaHora,
            porcentaje
          });
        }
      }

      siguienteEvento();
    });
  }

  // Garantiza que el callback se invoque exactamente una vez.
  function finalizar(error, resultado) {
    if (terminado) return;
    terminado = true;
    callback(error, resultado);
  }

  siguienteEvento();
}

// --- Punto de entrada ---
const umbral = Number(process.argv[2]) || UMBRAL_POR_DEFECTO;
const inicio = Date.now();

console.error(`Buscando sesiones con menos del ${umbral}% vendido...`);

listarSesionesEnRiesgo(umbral, (error, sesiones) => {
  if (error) {
    console.error(`No se pudo generar el informe: ${error.message}`);
    process.exitCode = 2;
    return;
  }

  console.error(`Consultados ${EVENTOS.length} eventos en ${Date.now() - inicio} ms`);

  if (sesiones.length === 0) {
    console.log('Sin sesiones en riesgo.');
    return;
  }

  console.table(sesiones);
  process.exitCode = 1;   // Codigo distinto de 0 para un sistema de avisos.
});
node src/laboratorio/informe-riesgo.js 20
Buscando sesiones con menos del 20% vendido...
Consultados 3 eventos en 128 ms
┌─────────┬───────────┬──────────────────────┬─────────────┬───────────────────────┬────────────┐
│ (index) │ eventoId  │ titulo               │ sesionId    │ fechaHora             │ porcentaje │
├─────────┼───────────┼──────────────────────┼─────────────┼───────────────────────┼────────────┤
│ 0       │ 'evt-002' │ 'Noche de Monologos' │ 'ses-002-3' │ '2026-10-17T21:30:00' │ 10         │
└─────────┴───────────┴──────────────────────┴─────────────┴───────────────────────┴────────────┘

Respuestas a las preguntas:

  • Con 40 ms por evento en serie: ~120 ms. Los tiempos se suman porque cada consulta empieza cuando termina la anterior.
  • Si se cargaran a la vez: ~40 ms. El coste sería el del evento más lento, no la suma.
  • Por qué no puedes hacerlo con esta estructura: siguienteEvento está construida sobre la premisa de que cada paso llama al siguiente. Para paralelizar harían falta un contador de respuestas pendientes, un array de resultados indexado y una guarda para llamar al callback final solo cuando el contador llegue a cero — y todo ello sin llamar dos veces al callback si uno falla. Es perfectamente posible (es lo que hacía async.parallel), pero es maquinaria manual y propensa a errores. En la lección siguiente, Promise.all resuelve exactamente esto en una línea.

Solución 3

// src/laboratorio/compra-plana.js
// Flujo de compra completo de Escena Viva, con estructura plana y compensacion.

const LATENCIA_MS = 30;
let secuenciaPedido = 0;
let secuenciaEntrada = 0;

// --- Pasos simulados ---

function crearPedido(usuarioId, sesion, cantidad, callback) {
  setTimeout(() => {
    secuenciaPedido++;
    callback(null, {
      id: `ped-${String(secuenciaPedido).padStart(3, '0')}`,
      usuarioId,
      sesionId: sesion.id,
      cantidad,
      totalCentimos: cantidad * sesion.precioCentimos,
      estado: 'pendiente'
    });
  }, LATENCIA_MS);
}

function cobrarPedido(pedido, callback) {
  setTimeout(() => {
    if (pedido.totalCentimos > 20000) {
      const error = new Error(
        `Pago rechazado: ${(pedido.totalCentimos / 100).toFixed(2)} EUR supera el limite`
      );
      error.codigo = 'PAGO_RECHAZADO';
      callback(error);
      return;
    }
    callback(null, { ...pedido, estado: 'pagado' });
  }, LATENCIA_MS);
}

function emitirEntradas(pedido, callback) {
  setTimeout(() => {
    const anio = new Date().getFullYear();
    const entradas = [];
    for (let i = 0; i < pedido.cantidad; i++) {
      secuenciaEntrada++;
      entradas.push({
        codigo: `EV-${anio}-${String(secuenciaEntrada).padStart(6, '0')}`,
        sesionId: pedido.sesionId,
        pedidoId: pedido.id,
        estado: 'valida'
      });
    }
    pedido.estado = 'emitido';
    callback(null, entradas);
  }, LATENCIA_MS);
}

function liberarAforo(sesionId, cantidad, callback) {
  setTimeout(() => {
    buscarSesion(sesionId, (error, resultado) => {
      if (error) {
        callback(error);
        return;
      }
      resultado.sesion.vendidas -= cantidad;
      callback(null, resultado.sesion.aforo - resultado.sesion.vendidas);
    });
  }, LATENCIA_MS);
}

// --- Flujo de compra, plano ---

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) {
    if (error) return fallo('reservar-aforo', error);
    contexto.aforoReservado = true;
    contexto.reserva = reserva;
    buscarSesion(sesionId, alBuscarSesion);
  }

  function alBuscarSesion(error, resultado) {
    if (error) return compensarYFallar('buscar-sesion', error);
    contexto.sesion = resultado.sesion;
    crearPedido(usuarioId, resultado.sesion, cantidad, alCrearPedido);
  }

  function alCrearPedido(error, pedido) {
    if (error) return compensarYFallar('crear-pedido', error);
    contexto.pedido = pedido;
    cobrarPedido(pedido, alCobrar);
  }

  function alCobrar(error, pedidoPagado) {
    if (error) return compensarYFallar('cobrar', error);
    contexto.pedido = pedidoPagado;
    emitirEntradas(pedidoPagado, alEmitir);
  }

  function alEmitir(error, entradas) {
    if (error) return compensarYFallar('emitir', error);
    contexto.entradas = entradas;
    alTerminar(null, contexto);
  }

  // Punto unico de error, sin compensacion.
  function fallo(paso, error) {
    error.paso = paso;
    alTerminar(error, contexto);
  }

  // Punto unico de error CON compensacion del aforo ya reservado.
  function compensarYFallar(paso, error) {
    if (!contexto.aforoReservado) return fallo(paso, error);

    liberarAforo(sesionId, cantidad, (errorLiberar, libres) => {
      if (errorLiberar) {
        console.error(`CRITICO: aforo no liberado en ${sesionId}: ${errorLiberar.message}`);
      } else {
        console.error(`[compensacion] aforo liberado en ${sesionId}, quedan ${libres} libres`);
      }
      fallo(paso, error);
    });
  }
}

// --- Pruebas de los dos caminos ---

function mostrar(error, contexto) {
  if (error) {
    console.error(`[${error.codigo || 'ERROR'}] fallo en "${error.paso}": ${error.message}`);
    return;
  }
  console.log('');
  console.log(`Pedido ${contexto.pedido.id} - ${contexto.pedido.estado}`);
  console.log(`  Evento : ${contexto.evento.titulo}`);
  console.log(`  Sesion : ${contexto.sesion.id}`);
  console.log(`  Importe: ${(contexto.pedido.totalCentimos / 100).toFixed(2)} EUR`);
  for (const entrada of contexto.entradas) {
    console.log(`  ${entrada.codigo}  ${entrada.estado}`);
  }
}

// Camino feliz: 3 x 18,00 = 54,00 EUR
comprarEntradas('asis-001', 'evt-002', 'ses-002-2', 3, mostrar);

// Camino de rechazo: 5 x 42,00 = 210,00 EUR, supera el limite
comprarEntradas('asis-002', 'evt-003', 'ses-003-2', 5, mostrar);

Salida:

Pedido ped-001 - emitido
  Evento : Noche de Monologos
  Sesion : ses-002-2
  Importe: 54.00 EUR
  EV-2026-000001  valida
  EV-2026-000002  valida
  EV-2026-000003  valida
[compensacion] aforo liberado en ses-003-2, quedan 180 libres
[PAGO_RECHAZADO] fallo en "cobrar": Pago rechazado: 210.00 EUR supera el limite

Lo importante de esta solución no es que funcione, sino cuánto trabajo estructural ha costado que funcione:

  • Un objeto contexto para arrastrar el estado, porque los cierres anidados desaparecieron.
  • Una bandera aforoReservado para saber si hay algo que compensar.
  • Dos puntos de salida de error (fallo y compensarYFallar) en lugar de uno.
  • Un callback anidado más dentro de la propia compensación.

Todo esto es contabilidad manual que el lenguaje no te ayuda a llevar. Un try/finally haría el trabajo de la compensación en tres líneas… si try/catch funcionara con código asíncrono. Y esa es, justamente, la primera cosa que las promesas devuelven.

Conclusión

Has aprendido el patrón que sostiene la asincronía clásica de Node. Un callback es una función que entregas para que se llame más tarde, y Node fijó para ella una forma canónica: el error-first callback (error, resultado), con el error siempre en primer lugar —para que sea imposible ignorarlo— y null cuando todo fue bien. Has escrito la capa de datos asíncrona de Escena Viva con esa firma: buscarEvento, buscarSesion y reservarEntradas, con errores que llevan un codigo propio (AFORO_INSUFICIENTE, SESION_NO_ENCONTRADA) que en el Módulo 6 se traducirá directamente a estados HTTP.

Has interiorizado las dos reglas que no admiten excepción: llamar al callback exactamente una vez —de ahí el return obligatorio tras cada llamada— y llamarlo siempre de forma asíncrona, incluso en la ruta de validación rápida, usando process.nextTick para no "liberar a Zalgo" con una función que unas veces es síncrona y otras no. Y has visto, demostrado paso a paso sobre la pila de llamadas, por qué try/catch no captura los errores lanzados dentro de un callback asíncrono: el bloque try ya terminó cuando el bucle de eventos ejecuta tu función en una pila nueva.

Después construiste la pirámide de la muerte con el flujo de compra real de Escena Viva —buscar evento, comprobar aforo, crear pedido, cobrar, emitir entradas— y comprobaste que el problema no es estético: es el manejo de errores duplicado cinco veces, la compensación anidada dentro del error del cobro, las variables atrapadas en cada cierre y la imposibilidad de paralelizar lo que es independiente. Las técnicas clásicas de mitigación —funciones con nombre, retorno temprano, modularizar y un ayudante enSerie— la hicieron habitable, pero al precio de llevar el estado a mano en un objeto contexto y de escribir maquinaria que el lenguaje debería aportar.

Y aun así, los callbacks no son historia: los necesitas para EventEmitter, para los streams del Módulo 3, para el middleware de Express y para las muchas APIs de Node que nunca tendrán versión con promesas.

Lo que falta es una forma de que la asincronía vuelva a parecerse al código normal: que un resultado se devuelva, que un error se lance y se capture con try/catch, que los pasos independientes se lancen a la vez con una sola instrucción y que la compensación se escriba en un finally. Eso es exactamente lo que ofrece la siguiente lección, Promesas y async/await, donde convertirás con util.promisify las funciones que acabas de escribir y reescribirás esta misma pirámide de compra hasta dejarla plana y legible de arriba abajo.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados