El módulo datos/backlog.js con el que cerraste la lección anterior tiene una mentira dentro: devuelve las seis tareas instantáneamente, porque están escritas a mano en el propio fichero. En la aplicación real esos datos vivirán en un servidor, y pedirlos tardará entre cincuenta milisegundos y varios segundos, según la red. La pregunta que abre este bloque del curso es qué hace el programa mientras tanto. Y la respuesta obvia —«esperar»— resulta ser catastrófica en un lenguaje que ejecuta una sola cosa a la vez: mientras espera, no puede hacer absolutamente nada más, así que la página se congela. En esta lección entenderás por qué JavaScript necesita la asincronía, aprenderás las herramientas más antiguas para manejarla —setTimeout, setInterval y el patrón callback—, simularás la carga del backlog con latencia, y llegarás por tu propio pie al famoso callback hell, la pirámide de código que motivó la invención de las promesas.
Contenido
- Un solo hilo: qué significa y por qué importa
- Síncrono frente a asíncrono, en una línea temporal
setTimeout: programar para más tardesetInterval,clearTimeoutyclearInterval- Por qué
setTimeout(f, 0)no es inmediato - El patrón callback
- La convención error-first
- Simular la carga del backlog
- Encadenar tres operaciones: la pirámide
- Los cuatro problemas de los callbacks
try/catchno captura errores asíncronos- Mitigaciones parciales y por qué no bastan
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Un solo hilo: qué significa y por qué importa
JavaScript es un lenguaje de un solo hilo (single-threaded): existe una única pila de llamadas —la que estudiaste en 03-05— y en ella se ejecuta una sola función en cada instante. No hay dos trozos de tu código corriendo a la vez.
Eso tiene una ventaja enorme, que se aprecia mejor si has sufrido otros lenguajes: nunca tienes que preocuparte de que otro hilo cambie una variable a mitad de una función. El array de tareas no puede modificarse mientras tu reduce lo recorre. Toda la clase de errores llamada «condiciones de carrera sobre memoria compartida» sencillamente no existe.
Y tiene un inconveniente igual de grande: si una función tarda, todo lo demás espera. En el navegador, ese «todo lo demás» incluye repintar la pantalla, responder a los clics y animar cualquier cosa.
'use strict';
/** Bloquea el hilo durante los milisegundos indicados. NO hagas esto en producción. */
function esperarBloqueando(ms) {
const fin = Date.now() + ms;
while (Date.now() < fin) {
// girar en vacío, quemando el procesador
}
}
console.log('Cargando el backlog…');
esperarBloqueando(3000); // 3 segundos de parálisis total
console.log('Backlog cargado');Durante esos tres segundos la pestaña está muerta: los botones no responden, el texto no se puede seleccionar y, si el navegador decide que ya está bien, aparece el aviso de «la página no responde». Un usuario abandona mucho antes.
flowchart TD
A["Pila de llamadas<br/>(una sola)"] --> B["esperarBloqueando(3000)<br/>ocupa la pila 3 s"]
B --> C["⛔ Nada más puede ejecutarse:<br/>ni repintado, ni clics, ni temporizadores"]
La solución no es añadir hilos, sino no esperar: pedir el dato, decir qué hacer cuando llegue, y devolver el control inmediatamente para que el programa siga vivo. Eso es la programación asíncrona.
- Síncrono frente a asíncrono, en una línea temporal
Compara los dos estilos sobre el mismo problema: cargar el backlog y pintar el resumen.
// ── SÍNCRONO (hipotético): la función devuelve el resultado ───────
const backlog = cargarBacklogBloqueando(); // ⏳ el programa se detiene aquí
console.log(backlog.length); // 6
console.log('Listo');// ── ASÍNCRONO: la función no devuelve nada; avisa cuando termina ──
cargarBacklog((tareas) => { // ← qué hacer CUANDO llegue
console.log(tareas.length); // 6
});
console.log('Listo'); // ← se ejecuta ANTES que el 6Ese cambio de orden es lo primero que desconcierta. La salida del segundo bloque es:
Porque cargarBacklog no espera: registra la función que le pasas, devuelve el control de inmediato y el programa continúa. Cuando los datos están disponibles —diez, cien o mil milisegundos después—, se ejecuta la función registrada.
flowchart TD
subgraph S["Síncrono · 3,1 s de bloqueo"]
S1["cargarBacklog<br/>0 → 3000 ms<br/>⛔ hilo bloqueado"] --> S2["console.log(6)<br/>3000 ms"] --> S3["console.log('Listo')<br/>3001 ms"]
end
subgraph A["Asíncrono · 1 ms de hilo ocupado"]
A1["cargarBacklog(cb)<br/>0 ms · registra y vuelve"] --> A2["console.log('Listo')<br/>1 ms"]
A2 --> A3["… el hilo está LIBRE<br/>1 → 3000 ms"]
A3 --> A4["cb(tareas) · console.log(6)<br/>3000 ms"]
end
En la versión asíncrona los datos llegan en el mismo instante —la red tarda lo que tarda—, pero durante esos tres segundos el hilo está libre: la interfaz responde, las animaciones corren, otros datos se pueden pedir en paralelo.
Un matiz importante que a menudo se explica mal: la asincronía no hace nada más rápido. Lo que hace es no desperdiciar el hilo mientras se espera algo que no depende de él. La espera la hace otro: el sistema de red del navegador, el temporizador del sistema operativo, el disco. Tu código solo se apunta a que le avisen.
setTimeout: programar para más tarde
setTimeout: programar para más tardeLa forma más simple de código asíncrono es setTimeout, que registra una función para ejecutarse después de un tiempo mínimo.
'use strict';
console.log('1 · antes');
setTimeout(() => {
console.log('3 · dentro del timeout');
}, 1000);
console.log('2 · después');
// Salida:
// 1 · antes
// 2 · después
// 3 · dentro del timeout ← un segundo más tardeSu firma completa:
funcion: qué ejecutar. Es un callback: una función que tú escribes pero que llama otro.milisegundos: el retardo mínimo. Si se omite, vale 0.arg1, arg2…: argumentos que se pasarán al callback.- Devuelve un identificador, que sirve para cancelarlo.
setTimeout((persona, horas) => {
console.log(`Recordatorio: ${persona} tiene ${horas} h abiertas`);
}, 500, 'Iván', 25);
// Recordatorio: Iván tiene 25 h abiertasUna alternativa que verás mucho y que conviene evitar: pasar los argumentos con una flecha que los captura.
setTimeout(() => avisar('Iván', 25), 500); // ✓ perfectamente legítimo y más legible
setTimeout(avisar('Iván', 25), 500); // ✗ ERROR: llama a avisar YA y pasa su retorno
setTimeout('avisar("Iván", 25)', 500); // ✗ nunca: es eval encubiertoLa segunda línea es el error clásico: los paréntesis llaman a la función, así que setTimeout recibe el valor devuelto (normalmente undefined) en lugar de una función. Es el mismo despiste de 03-02 al pasar funciones como argumento.
setInterval, clearTimeout y clearInterval
setInterval, clearTimeout y clearIntervalsetInterval repite el callback cada N milisegundos, indefinidamente, hasta que se cancele.
'use strict';
let ronda = 0;
const idIntervalo = setInterval(() => {
ronda += 1;
console.log(`Comprobando tareas vencidas… ronda ${ronda}`);
if (ronda === 3) {
clearInterval(idIntervalo); // ← imprescindible: sin esto, sigue para siempre
console.log('Comprobación detenida');
}
}, 1000);Y clearTimeout cancela un setTimeout que todavía no se ha disparado:
const idAviso = setTimeout(() => {
console.log('⚠ El presupuesto de la carpintería lleva 3 días vencido');
}, 5000);
// Si la tarea se completa antes, el aviso ya no tiene sentido
tablero.cambiarEstado(6, 'en-curso');
clearTimeout(idAviso); // el callback nunca se ejecutará| Función | Qué hace | Cancelar con |
|---|---|---|
setTimeout(f, ms) |
Ejecuta f una vez, pasados al menos ms |
clearTimeout(id) |
setInterval(f, ms) |
Ejecuta f cada ms, sin fin |
clearInterval(id) |
Un aviso sobre setInterval que cuesta caro descubrir en producción: no espera a que el callback termine. Si programas un intervalo de 100 ms y el callback tarda 150 ms, las ejecuciones se solapan y se acumulan. Para trabajos de duración variable —consultar un servidor, por ejemplo— es más seguro un setTimeout que se reprograma a sí mismo cuando ha terminado:
/** Repetición segura: cada ciclo empieza cuando el anterior ha acabado. */
function comprobarPeriodicamente(intervalo) {
setTimeout(function ciclo() {
revisarVencidas(); // por larga que sea…
setTimeout(ciclo, intervalo); // …el siguiente ciclo se programa después
}, intervalo);
}Y una regla operativa que te ahorrará fugas de memoria (Módulo 9): guarda siempre el identificador y cancela cuando el trabajo deja de tener sentido. Un intervalo olvidado sigue corriendo, consumiendo batería y manteniendo vivas por referencia todas las variables que su callback captura.
- Por qué
setTimeout(f, 0) no es inmediato
setTimeout(f, 0) no es inmediatoEsta es una de las líneas más incomprendidas del lenguaje:
'use strict';
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
// Salida: A, C, BCon un retardo de cero milisegundos, B sigue saliendo el último. El motivo es que el segundo argumento no es «cuándo se ejecutará», sino «el tiempo mínimo que debe pasar antes de que sea candidato a ejecutarse». Y para ser candidato hace falta algo más: que la pila de llamadas esté vacía.
El mecanismo completo, en tres pasos:
setTimeoutentrega el callback al entorno (el navegador o Node), que arranca un temporizador. Esto no ocupa el hilo.- Cumplido el tiempo, el entorno coloca el callback en una cola de tareas pendientes.
- Cuando el código que se está ejecutando termina del todo y la pila queda vacía, se toma el primero de la cola y se ejecuta.
Por eso un setTimeout(f, 0) detrás de un bucle pesado no se ejecuta hasta que el bucle acaba:
setTimeout(() => console.log('Debería salir enseguida'), 0);
let suma = 0;
for (let i = 0; i < 500_000_000; i++) suma += i; // varios segundos
console.log('Bucle terminado');
// Bucle terminado
// Debería salir enseguida ← esperó a que la pila se vaciaraHay además un detalle histórico: por especificación, los temporizadores anidados a partir de cierta profundidad se elevan a un mínimo de 4 ms en los navegadores. Así que setTimeout(f, 0) es realmente «lo antes posible, y como mínimo dentro de unos milisegundos, cuando el hilo esté libre».
Ese uso —«ejecuta esto después de lo que estoy haciendo ahora»— es perfectamente legítimo y se llama ceder el hilo. Todo el mecanismo de colas que acabas de entrever tiene nombre propio, un algoritmo preciso y varias sutilezas más, y es el tema completo de El Bucle de Eventos y la Cola de Microtareas. De momento te basta con la regla: primero termina todo el código síncrono; después, los callbacks pendientes.
- El patrón callback
Un callback es una función que pasas a otra para que la llame más tarde. La idea la conoces desde 03-06: es exactamente lo que hacías con map, filter y sort. La diferencia es cuándo se llama.
| Tipo | Ejemplo | Cuándo se ejecuta el callback |
|---|---|---|
| Síncrono | [1,2,3].map((n) => n * 2) |
Inmediatamente, dentro de la llamada. map no vuelve hasta terminar |
| Asíncrono | setTimeout(() => …, 1000) |
Más tarde, cuando la pila esté libre. La función vuelve al instante |
Un callback asíncrono se reconoce por una señal inequívoca: la función que lo recibe no devuelve el resultado. Compara:
// Síncrono: el resultado sale por return
function buscarTarea(backlog, id) {
return backlog.find((t) => t.id === id) ?? null;
}
const tarea = buscarTarea(backlog, 6);
// Asíncrono: el resultado sale por el callback
function buscarTareaEnServidor(id, callback) {
setTimeout(() => {
callback(backlog.find((t) => t.id === id) ?? null);
}, 300);
// no hay return: la función acaba aquí, sin resultado
}
buscarTareaEnServidor(6, (tarea) => console.log(tarea.titulo));De ahí sale la regla que rige toda esta lección:
Un valor producido de forma asíncrona no se puede devolver con
return. Solo se puede entregar a alguien que espera recibirlo.
Y de ahí también el error más frecuente de quien empieza:
function buscarTareaMal(id) {
let resultado = null;
setTimeout(() => { resultado = backlog.find((t) => t.id === id); }, 300);
return resultado; // ✗ SIEMPRE null: el return ocurre 300 ms antes
}
console.log(buscarTareaMal(6)); // nullNo hay ninguna forma de arreglar esa función manteniendo la firma. El valor no existe todavía cuando se ejecuta el return. Hay que cambiar el diseño, no el código.
- La convención error-first
Si el resultado viaja por el callback, los errores también tienen que hacerlo. Node.js estableció una convención que se adoptó en todas partes: el callback recibe el error como primer argumento y el resultado como segundo.
- Si todo fue bien:
callback(null, datos). - Si algo falló:
callback(new Error('…')), sin segundo argumento.
'use strict';
import { ErrorDeDatos } from '../modelo/errores.js';
/** Busca una tarea en el "servidor" (simulado). Convención error-first. */
function buscarTareaEnServidor(id, callback) {
setTimeout(() => {
if (typeof id !== 'number') {
callback(new ErrorDeDatos(`El id debe ser un número, recibido: ${typeof id}`));
return; // ← imprescindible: sin él seguiría
}
const tarea = datosBacklog.find((t) => t.id === id);
if (tarea === undefined) {
callback(new ErrorDeDatos(`No existe la tarea ${id}`));
return;
}
callback(null, tarea); // éxito: primer argumento null
}, 300);
}
buscarTareaEnServidor(6, (error, tarea) => {
if (error) { // 1 · comprobar el error SIEMPRE, y primero
console.error(`✗ ${error.message}`);
return;
}
console.log(`✓ ${tarea.titulo}`); // 2 · aquí ya se sabe que hay resultado
});
// ✓ Presupuesto de la carpintería
buscarTareaEnServidor(99, (error, tarea) => {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log(`✓ ${tarea.titulo}`);
});
// ✗ No existe la tarea 99Tres detalles que hay que respetar a rajatabla:
- Comprobar el error lo primero. Si te saltas ese
if,tareaseráundefinedy el error se transformará en unTypeErrorconfuso tres líneas más abajo. returndespués de llamar al callback con error. Es el mismoreturnde guarda que aprendiste en 02-01: sin él, la función continúa y puede acabar llamando al callback dos veces, algo que rompe a quien lo consume.- Llamar al callback exactamente una vez. Ni cero (el consumidor se queda esperando para siempre, sin ningún error) ni dos.
- Simular la carga del backlog
Con todo lo anterior, ya podemos sustituir el crearBacklog() instantáneo de 05-04 por una versión que se comporte como el mundo real: con latencia y con posibilidad de fallo.
// js/datos/backlogSimulado.js
import { Tarea } from '../modelo/tarea.js';
import { ErrorDeDatos } from '../modelo/errores.js';
import { datosBacklog } from './backlog.js';
/**
* Simula la carga del backlog desde un servidor.
* En el Módulo 7 esto será una llamada real con fetch (07-02);
* aquí la latencia se finge con setTimeout.
*
* @param {Function} callback (error, tareas) => void
* @param {Object} [opciones]
* @param {number} [opciones.latencia=400] milisegundos de espera simulada
* @param {boolean} [opciones.fallar=false] fuerza un error, para probar el camino malo
*/
export function leerBacklogSimulado(callback, opciones = {}) {
const { latencia = 400, fallar = false } = opciones;
setTimeout(() => {
if (fallar) {
callback(new ErrorDeDatos('El servidor de Taller Nómada no responde (503).'));
return;
}
try {
const tareas = datosBacklog.map((datos) => new Tarea(datos));
callback(null, tareas);
} catch (error) {
callback(new ErrorDeDatos('El backlog recibido no es válido.', error));
}
}, latencia);
}Y su uso desde app.js:
import { leerBacklogSimulado } from './datos/backlogSimulado.js';
import { Tablero } from './modelo/tablero.js';
import { HOY } from './util/fechas.js';
console.log('⏳ Cargando el backlog…');
leerBacklogSimulado((error, tareas) => {
if (error) {
console.error(`✗ ${error.message}`);
return;
}
const tablero = new Tablero('Taller Nómada', tareas);
console.log('✓ Backlog cargado');
console.log(tablero.resumen(HOY));
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }
});
console.log('La aplicación sigue respondiendo mientras carga');
// Salida:
// ⏳ Cargando el backlog…
// La aplicación sigue respondiendo mientras carga
// ✓ Backlog cargado
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }Fíjate en el orden: el mensaje de «sigue respondiendo» sale antes que los datos. Esa es la prueba de que el hilo quedó libre. Y observa también un detalle de diseño: el try/catch está dentro del setTimeout, envolviendo la construcción de las tareas, y convierte cualquier ErrorDeValidacion del constructor en una llamada al callback con error. El motivo de que tenga que estar ahí dentro lo verás en el apartado 11.
Recordatorio de progresión:
leerBacklogSimuladofinge la red consetTimeout. La API de verdad —fetch, códigos de estado HTTP, cabeceras y todo lo demás— llega en 07-02, y la forma robusta de manejar sus fallos y sus tiempos de espera, en 07-03. Aquí lo que importa es la forma del código asíncrono, no de dónde salen los datos.
- Encadenar tres operaciones: la pirámide
Hasta aquí, los callbacks parecen razonables. El problema aparece cuando una operación asíncrona depende del resultado de otra. Marta pide un informe semanal que requiere tres pasos, y cada uno necesita lo que produjo el anterior:
- Cargar el backlog.
- Con los responsables que aparezcan, cargar sus horas contratadas.
- Con las dos cosas, generar el informe y guardarlo.
Añadimos los dos módulos simulados que faltan:
// js/datos/equipoSimulado.js
import { ErrorDeDatos } from '../modelo/errores.js';
const CONTRATOS = { 'Iván': 30, 'Marta': 20, 'Lucía': 35 }; // horas semanales contratadas
export function leerHorasEquipoSimulado(nombres, callback, latencia = 300) {
setTimeout(() => {
const horas = {};
for (const nombre of nombres) {
if (!Object.hasOwn(CONTRATOS, nombre)) {
callback(new ErrorDeDatos(`${nombre} no figura en el equipo de Taller Nómada.`));
return;
}
horas[nombre] = CONTRATOS[nombre];
}
callback(null, horas);
}, latencia);
}
export function guardarInformeSimulado(informe, callback, latencia = 200) {
setTimeout(() => {
if (informe.lineas.length === 0) {
callback(new ErrorDeDatos('No se guarda un informe vacío.'));
return;
}
callback(null, { guardado: true, id: `inf-${Date.now()}`, lineas: informe.lineas.length });
}, latencia);
}Y ahora el informe, escrito con callbacks:
import { leerBacklogSimulado } from './datos/backlogSimulado.js';
import { leerHorasEquipoSimulado, guardarInformeSimulado } from './datos/equipoSimulado.js';
import { Tablero } from './modelo/tablero.js';
import { HOY } from './util/fechas.js';
console.log('⏳ Generando el informe semanal…');
leerBacklogSimulado((errorBacklog, tareas) => {
if (errorBacklog) {
console.error(`✗ Fallo al cargar el backlog: ${errorBacklog.message}`);
return;
}
const tablero = new Tablero('Taller Nómada', tareas);
const carga = tablero.horasPorResponsable();
const nombres = Object.keys(carga);
leerHorasEquipoSimulado(nombres, (errorEquipo, contratadas) => {
if (errorEquipo) {
console.error(`✗ Fallo al cargar el equipo: ${errorEquipo.message}`);
return;
}
const lineas = nombres.map((nombre) => ({
nombre,
asignadas: carga[nombre],
contratadas: contratadas[nombre],
sobrecarga: carga[nombre] > contratadas[nombre]
}));
guardarInformeSimulado({ fecha: HOY, lineas }, (errorGuardar, recibo) => {
if (errorGuardar) {
console.error(`✗ Fallo al guardar: ${errorGuardar.message}`);
return;
}
console.log(`✓ Informe ${recibo.id} guardado con ${recibo.lineas} líneas`);
for (const l of lineas) {
const marca = l.sobrecarga ? '⚠' : '·';
console.log(` ${marca} ${l.nombre.padEnd(8)} ${l.asignadas} h asignadas / ${l.contratadas} h contratadas`);
}
});
});
});La salida es correcta:
⏳ Generando el informe semanal… ✓ Informe inf-1758... guardado con 3 líneas · Iván 25 h asignadas / 30 h contratadas · Marta 6 h asignadas / 20 h contratadas · Lucía 14 h asignadas / 35 h contratadas
Pero mira la forma del código. Cada operación mete la siguiente un nivel más adentro, y el cierre final es esa escalera de }); que se ha ganado su propio apodo:
flowchart TD
A["leerBacklogSimulado((e, tareas) => {"] --> B[" leerHorasEquipoSimulado((e, horas) => {"]
B --> C[" guardarInformeSimulado((e, recibo) => {"]
C --> D[" console.log(…)"]
D --> E[" });"]
E --> F[" });"]
F --> G["});"]
Esto se llama callback hell o pirámide de la perdición. Con tres pasos aún se lee; con seis —cargar, validar, enriquecer, calcular, guardar, notificar— es ilegible. Y son solo tres pasos secuenciales: si además hubiera que hacer dos cosas en paralelo y esperar a ambas, tendrías que inventarte un contador manual.
- Los cuatro problemas de los callbacks
La indentación es lo que se ve, pero no es lo más grave. Los problemas reales son cuatro.
Problema 1: manejo de errores repetido. Cuenta los bloques if (error) { console.error(...); return; } del ejemplo anterior: tres, uno por nivel, prácticamente idénticos. No hay forma de escribir «si algo falla en cualquier punto de esta secuencia, haz esto». Cada nivel se defiende solo, y basta olvidar un if para que un fallo pase desapercibido y reviente más adelante con un mensaje que no tiene nada que ver.
Problema 2: el orden de lectura no es el orden de ejecución. El código se lee de arriba abajo pero se ejecuta en un baile de saltos. Lo que ocurre después del guardarInformeSimulado está dentro de él, y lo que ocurre después de todo está en el nivel más profundo. Nuestro cerebro lee secuencias; esto es un árbol.
Problema 3: inversión de control. Cuando escribes guardarInformeSimulado(informe, miCallback), estás entregando tu función a un tercero. A partir de ahí, ya no controlas nada:
| Lo que puede hacer mal quien recibe tu callback | Consecuencia |
|---|---|
| No llamarlo nunca | Tu programa se queda colgado, sin error ni pista |
| Llamarlo dos veces | El informe se guarda dos veces, el contador se duplica |
| Llamarlo demasiado pronto (síncronamente) | Orden de ejecución impredecible según el caso |
| Llamarlo con los argumentos al revés | Tratas un error como si fuera el resultado |
| Tragarse una excepción de tu callback | Los fallos desaparecen en silencio |
Con una función tuya no pasa; con una librería de terceros, todo eso ocurre de verdad. Y no tienes ninguna herramienta para protegerte, salvo leer su código.
Problema 4: componer es imposible. Con funciones normales, combinar es trivial: pipe(filtrar, ordenar, resumir), como hiciste en 03-06. Con callbacks no hay nada equivalente. Operaciones tan comunes como «haz estas tres cosas a la vez y avísame cuando todas terminen» o «reintenta esto tres veces si falla» requieren escribir a mano contadores, banderas y comprobaciones:
// "Ejecutar dos cargas en paralelo y seguir cuando ambas terminen", a mano
let pendientes = 2;
let resultadoA = null;
let resultadoB = null;
let yaFallo = false;
function comprobarSiTerminamos() {
pendientes -= 1;
if (pendientes === 0 && !yaFallo) continuar(resultadoA, resultadoB);
}
cargarA((error, datos) => {
if (error) { yaFallo = true; return manejar(error); }
resultadoA = datos;
comprobarSiTerminamos();
});
cargarB((error, datos) => {
if (error) { yaFallo = true; return manejar(error); }
resultadoB = datos;
comprobarSiTerminamos();
});Cuatro variables de estado y una función auxiliar para expresar una idea de media línea. En la lección siguiente esto será Promise.all([cargarA(), cargarB()]).
try/catch no captura errores asíncronos
try/catch no captura errores asíncronosEn 02-05 quedó anotado un aviso que ahora puedes entender del todo: try/catch no captura errores que ocurren dentro de un callback asíncrono.
'use strict';
try {
setTimeout(() => {
throw new Error('Fallo dentro del temporizador');
}, 100);
console.log('El try terminó sin problemas');
} catch (error) {
console.error('Capturado:', error.message); // ← NUNCA se ejecuta
}
// Salida:
// El try terminó sin problemas
// …100 ms después: Uncaught Error: Fallo dentro del temporizadorLa razón es exactamente la del apartado 5, y ahora encaja con lo que sabes de la pila de llamadas de 03-05. try/catch protege una región de la pila: captura lo que se lance mientras esas líneas están ejecutándose. Cuando el callback se ejecuta, cien milisegundos más tarde, la pila que contenía el try ya no existe; el callback arranca sobre una pila nueva y vacía, sin ningún try alrededor.
flowchart TD
subgraph T1["t = 0 ms · pila con el try"]
A["try { … }"] --> B["setTimeout registra el callback"]
B --> C["el try termina · la pila se vacía"]
end
subgraph T2["t = 100 ms · pila nueva"]
D["callback()"] --> E["throw Error"]
E --> F["⚠ nadie lo captura:<br/>aquí no hay ningún try"]
end
C -.->|"el tiempo pasa"| D
La única forma de capturarlo con callbacks es poner el try/catch dentro del callback, que es justo lo que hicimos en leerBacklogSimulado:
setTimeout(() => {
try {
const tareas = datosBacklog.map((datos) => new Tarea(datos));
callback(null, tareas);
} catch (error) { // ✓ el try está en la misma pila que el throw
callback(new ErrorDeDatos('El backlog recibido no es válido.', error));
}
}, latencia);Y aquí está la consecuencia de diseño más importante de toda la lección: con callbacks, los errores no se propagan solos. En el código síncrono de 02-05, un throw en el fondo de diez llamadas subía por la pila hasta el primer try/catch, sin que las funciones intermedias hicieran nada. Con callbacks, cada nivel tiene que capturar su error y pasarlo a mano al callback de arriba. El mecanismo de propagación automática del lenguaje, que era una de sus mejores características, deja de funcionar en cuanto cruzas una frontera asíncrona.
- Mitigaciones parciales y por qué no bastan
Existen técnicas para aliviar la pirámide. Vale la pena conocerlas porque el código antiguo las usa, y porque entender sus límites explica por qué hizo falta un mecanismo nuevo.
Mitigación 1: nombrar las funciones y aplanar. En lugar de anidar flechas anónimas, se declaran funciones con nombre y se pasan por referencia.
'use strict';
let tableroActual = null;
let cargaActual = null;
function alCargarBacklog(error, tareas) {
if (error) return abortar('cargar el backlog', error);
tableroActual = new Tablero('Taller Nómada', tareas);
cargaActual = tableroActual.horasPorResponsable();
leerHorasEquipoSimulado(Object.keys(cargaActual), alCargarEquipo);
}
function alCargarEquipo(error, contratadas) {
if (error) return abortar('cargar el equipo', error);
const lineas = Object.keys(cargaActual).map((nombre) => ({
nombre, asignadas: cargaActual[nombre], contratadas: contratadas[nombre]
}));
guardarInformeSimulado({ fecha: HOY, lineas }, alGuardarInforme);
}
function alGuardarInforme(error, recibo) {
if (error) return abortar('guardar el informe', error);
console.log(`✓ Informe ${recibo.id} guardado`);
}
function abortar(fase, error) {
console.error(`✗ Fallo al ${fase}: ${error.message}`);
}
leerBacklogSimulado(alCargarBacklog);Es mejor: la indentación desaparece, cada función tiene nombre y las trazas de error de 03-05 son legibles. Pero mira lo que ha costado:
- Han aparecido variables compartidas (
tableroActual,cargaActual) para pasar datos entre pasos. Es estado global reintroducido por la puerta de atrás, con todos los problemas que aprendiste a evitar en 03-04. - La secuencia ya no se lee en ningún sitio. Para saber qué pasa después de cargar el backlog hay que buscar la última línea de
alCargarBacklog, ir aalCargarEquipo, y así sucesivamente. El orden está repartido por el fichero. - El manejo de errores sigue repetido en cada función, aunque ahora delegue en
abortar.
Mitigación 2: modularizar. Meter cada paso en su propio módulo (05-04) mejora la organización, pero no cambia nada de lo anterior: los problemas son de forma del control de flujo, no de dónde vive el código.
Mitigación 3: librerías de control de flujo. Alrededor de 2012 se popularizaron librerías como async con funciones tipo waterfall, series y parallel:
// Estilo de librería de control de flujo (ilustrativo)
serie([cargarBacklog, cargarEquipo, guardarInforme], (error, resultados) => { … });Funcionaban, pero eran una convención externa: había que aprenderlas, no formaban parte del lenguaje, y la inversión de control del problema 3 seguía intacta, porque seguías entregando tus funciones a un tercero.
Ninguna mitigación toca los dos problemas de fondo:
| Problema | ¿Lo resuelve nombrar/modularizar? |
|---|---|
| Indentación en pirámide | Sí |
| Errores repetidos en cada nivel | No |
try/catch inútil sobre lo asíncrono |
No |
| Inversión de control | No |
| Componer (paralelo, reintentos, timeouts) | No |
Lo que hace falta es que una operación asíncrona devuelva algo: un objeto que represente «el resultado que llegará», que se pueda guardar en una variable, pasar a una función, encadenar y combinar, y que propague los errores automáticamente como hacía la pila síncrona. Ese objeto existe, se llama promesa, y es el tema de la lección siguiente.
Errores Comunes y Consejos
- Intentar
returndesde un callback asíncrono. El valor no existe cuando se ejecuta elreturn. Si te ves escribiendolet resultado; setTimeout(() => resultado = …); return resultado;, para: el diseño tiene que cambiar, no el código. - Llamar a la función en lugar de pasarla.
setTimeout(avisar(), 100)ejecutaavisarinmediatamente. Se pasaavisaro() => avisar(...). - Olvidar el
returntras llamar al callback con error. La función sigue y acaba llamando al callback una segunda vez con datos incompletos. Es un fallo endiablado de depurar. - No comprobar el error, o comprobarlo después de usar el resultado. La primera línea del callback debe ser
if (error) { …; return; }. - Envolver una llamada asíncrona en
try/catchesperando capturar algo. No funciona: elcatchpertenece a una pila que ya no existe. Eltryva dentro del callback. - Olvidar
clearInterval. Un intervalo huérfano corre para siempre, gasta batería y mantiene vivas por referencia todas las variables que captura: es una fuga de memoria de manual (Módulo 9). - Confiar en el valor exacto del retardo.
setTimeout(f, 100)significa «no antes de 100 ms», nunca «exactamente a los 100 ms». Para medir tiempo real, usa marcas conDate.now()en lugar de contar temporizadores. - Bucles con temporizadores dentro.
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0)imprime0 1 2conletpero3 3 3convar: es exactamente el caso de ámbito por bloque de 03-04, ahora con consecuencias asíncronas. - Consejo: nombra los callbacks por lo que ocurre, no por lo que hacen:
alCargarBacklog,alFallarGuardado. Cuando aparezcan en una traza de error a las tres de la mañana, lo agradecerás.
Ejercicios
Ejercicio 1 — Predecir la salida. Sin ejecutarlo, escribe el orden exacto en que aparecen las seis líneas y justifica cada una.
console.log('1');
setTimeout(() => console.log('2'), 0);
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(`3.${i}`), 100 - i * 50);
}
console.log('4');Ejercicio 2 — contarTareasPorResponsableSimulado. Escribe una función con la convención error-first que reciba (responsable, callback) y, tras 250 ms simulados, entregue { responsable, abiertas, horas } con los datos del backlog canónico. Debe fallar con ErrorDeDatos si el responsable no existe o si no es un string. Pruébala con 'Iván' (debe dar 3 tareas abiertas y 25 h), con 'Nadie' y con 42.
Ejercicio 3 — Reintentos con callbacks. Escribe conReintentos(operacion, intentos, callback) que ejecute una operación asíncrona con estilo error-first y, si falla, la reintente hasta intentos veces antes de rendirse, esperando 200 ms entre intentos. Pruébala con una operación que falle las dos primeras veces y funcione a la tercera. Después responde: ¿cuántas líneas te ha costado y qué parte es lógica de reintento y cuál es fontanería de callbacks?
Soluciones
Ejercicio 1
1 · síncrono 4 · síncrono: todo el código de nivel superior se ejecuta primero 3.2 · retardo 0 ms (100 - 2*50) 2 · retardo 0 ms, pero se REGISTRÓ antes que 3.2… ver nota 3.1 · retardo 50 ms 3.0 · retardo 100 ms
El punto interesante es el orden entre 2 y 3.2. Ambos tienen retardo 0, y cuando dos temporizadores vencen al mismo tiempo se ejecutan en el orden en que se registraron, así que en la práctica verás 2 antes que 3.2:
Dos lecciones. La primera: todo el código síncrono va primero, sin excepción; 4 sale antes que cualquier temporizador aunque estos tengan retardo cero. La segunda: los callbacks no salen en el orden en que se escribieron, sino por tiempo de vencimiento; el bucle registra los retardos decrecientes 100, 50 y 0, así que la salida va al revés que el bucle. Y observa que i vale lo correcto en cada uno gracias a let, que crea una variable nueva por vuelta (03-04); con var habrías obtenido tres 3.3.
Ejercicio 2
'use strict';
import { datosBacklog } from './datos/backlog.js';
import { ErrorDeDatos } from './modelo/errores.js';
/**
* Cuenta las tareas abiertas y las horas de un responsable.
* @param {string} responsable
* @param {Function} callback (error, resumen) => void
*/
export function contarTareasPorResponsableSimulado(responsable, callback) {
setTimeout(() => {
if (typeof responsable !== 'string') {
callback(new ErrorDeDatos(`El responsable debe ser un string, recibido: ${typeof responsable}`));
return;
}
const suyas = datosBacklog.filter((t) => t.responsable === responsable);
if (suyas.length === 0) {
callback(new ErrorDeDatos(`${responsable} no tiene tareas en el backlog.`));
return;
}
const abiertas = suyas.filter((t) => t.estado !== 'hecha');
callback(null, {
responsable,
abiertas: abiertas.length,
horas: abiertas.reduce((s, t) => s + t.horasEstimadas, 0)
});
}, 250);
}
function mostrar(error, resumen) {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log(`✓ ${resumen.responsable}: ${resumen.abiertas} abiertas, ${resumen.horas} h`);
}
contarTareasPorResponsableSimulado('Iván', mostrar); // ✓ Iván: 3 abiertas, 25 h
contarTareasPorResponsableSimulado('Nadie', mostrar); // ✗ Nadie no tiene tareas en el backlog.
contarTareasPorResponsableSimulado(42, mostrar); // ✗ El responsable debe ser un string, recibido: numberLos 25 h de Iván son los canónicos: 12 de la sala polivalente, 8 de la guía de encuadernación y 5 del presupuesto de la carpintería. Fíjate en las tres llamadas seguidas: las tres se registran de inmediato, sin que ninguna espere a la anterior, y sus resultados llegan a los 250 ms más o menos a la vez. Eso es paralelismo de operaciones asíncronas, gratis, sin ninguna coordinación. El problema —como viste en el apartado 10— aparece cuando necesitas saber cuándo han terminado todas.
Ejercicio 3
'use strict';
/**
* Ejecuta una operación error-first reintentándola si falla.
* @param {Function} operacion (callback) => void
* @param {number} intentos número máximo de intentos
* @param {Function} callback (error, resultado) => void
*/
function conReintentos(operacion, intentos, callback) {
let restantes = intentos;
function intentar() {
operacion((error, resultado) => {
if (!error) {
callback(null, resultado);
return;
}
restantes -= 1;
if (restantes === 0) {
callback(new Error(`Fallaron los ${intentos} intentos. Último error: ${error.message}`));
return;
}
console.log(` ↻ reintentando… quedan ${restantes}`);
setTimeout(intentar, 200);
});
}
intentar();
}
// Operación de prueba: falla las dos primeras veces
let veces = 0;
function servidorInestable(callback) {
veces += 1;
const intento = veces;
setTimeout(() => {
if (intento < 3) callback(new Error(`503 en el intento ${intento}`));
else callback(null, { backlog: 6, horas: 48 });
}, 100);
}
conReintentos(servidorInestable, 4, (error, datos) => {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log('✓ Recibido:', datos);
});
// Salida:
// ↻ reintentando… quedan 3
// ↻ reintentando… quedan 2
// ✓ Recibido: { backlog: 6, horas: 48 }Respondiendo a la pregunta del enunciado: son unas veinte líneas, y solo tres expresan la idea de reintento (restantes -= 1, la comprobación de agotamiento y el setTimeout(intentar, 200)). Todo lo demás es fontanería: la función interna intentar que existe solo para poder repetirse, la comprobación manual del error, los return de guarda tras cada llamada al callback y el cierre de la anidación. Y hay una fragilidad escondida: si operacion llamara a su callback dos veces —el problema 3 del apartado 10—, conReintentos llamaría dos veces al suyo, y nada en este código lo impide.
Guarda este ejercicio. Cuando en la lección siguiente lo reescribas con promesas y async/await, la lógica de reintento cabrá en un for de cinco líneas con un try/catch normal, y el problema de la doble llamada desaparecerá por construcción.
Conclusión
Has entendido el problema de fondo y las herramientas de primera generación para resolverlo. JavaScript tiene un solo hilo, lo que le ahorra toda una categoría de errores de concurrencia pero le impone una regla implacable: mientras una función se ejecuta, nada más puede hacerlo. Bloquear tres segundos no significa «tardar tres segundos», significa congelar la aplicación entera durante tres segundos. La salida no es esperar mejor, sino no esperar: pedir el dato, declarar qué hacer cuando llegue y devolver el control de inmediato. Por eso la salida de un programa asíncrono desconcierta al principio —'Listo' aparece antes que los datos—, y por eso esa inversión de orden es precisamente la señal de que el hilo ha quedado libre.
Dominas las piezas básicas. setTimeout para programar una ejecución futura, con su identificador y su clearTimeout; setInterval para repetir, con la advertencia de que no espera a que el callback termine y de que un intervalo olvidado es una fuga de memoria; y la explicación de por qué setTimeout(f, 0) no es inmediato: el retardo es un mínimo, y además el callback tiene que esperar a que la pila de llamadas se vacíe. Sabes reconocer un callback asíncrono por una señal inequívoca —la función que lo recibe no devuelve el resultado—, y de ahí la regla que gobierna todo: un valor producido de forma asíncrona no se puede devolver con return, solo entregar. Y conoces la convención error-first de Node, (error, resultado), con sus tres disciplinas: comprobar el error lo primero, poner return tras cada llamada al callback, y llamarlo exactamente una vez.
Con eso has construido leerBacklogSimulado, leerHorasEquipoSimulado y guardarInformeSimulado, has cargado el backlog canónico con latencia fingida —48 h, 45 abiertas, 1 vencida, esfuerzo 124, con la aplicación respondiendo mientras tanto— y has encadenado los tres pasos del informe semanal de Marta hasta ver la pirámide con tus propios ojos. Y has diagnosticado por qué duele, que es lo importante: el manejo de errores se repite en cada nivel, el orden de lectura deja de coincidir con el de ejecución, entregar tu función a un tercero es una inversión de control sin garantías —puede no llamarla, llamarla dos veces o tragarse sus excepciones—, y componer operaciones (dos en paralelo, un reintento, un tiempo máximo) exige inventar contadores y banderas a mano. Por encima de todo, has confirmado el aviso que quedó pendiente en 02-05: try/catch no captura errores asíncronos, porque cuando el callback se ejecuta la pila que contenía el try ya no existe. La propagación automática de errores, una de las mejores características del lenguaje, deja de funcionar en cuanto cruzas una frontera asíncrona.
Las mitigaciones —nombrar las funciones, aplanar la pirámide, modularizar, usar librerías de control de flujo— arreglan la indentación y poco más, y encima reintroducen variables compartidas para pasar datos entre pasos. El problema no era estético. Lo que falta es que una operación asíncrona devuelva un valor: un objeto que represente el resultado futuro, que se pueda guardar en una variable, pasar a una función, encadenar sin anidar, combinar en paralelo y, sobre todo, que propague los errores por sí mismo como hacía la pila síncrona. Ese objeto existe desde ES2015, se llama promesa, y con la sintaxis async/await que se construyó encima permite escribir código asíncrono que se lee exactamente igual que el síncrono —try/catch incluido—. Es el tema de Promesas y Async/Await, donde volverás a escribir el informe semanal de Marta y comprobarás cuánto código desaparece.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
