Callbacks
La programación asíncrona es el corazón de Node.js. Mientras que en muchos lenguajes las operaciones bloquean la ejecución hasta completarse, Node.js utiliza un enfoque diferente: delega las tareas pesadas al sistema operativo y continúa ejecutando código mientras espera los resultados.
Los callbacks son funciones que se pasan como argumentos a otras funciones y se ejecutan cuando una operación asíncrona se completa. Este patrón permite que Node.js mantenga su naturaleza no bloqueante, procesando miles de operaciones concurrentes sin crear nuevos hilos del sistema operativo.
¿Qué es un callback?
Un callback es simplemente una función que se ejecuta "de vuelta" cuando otra función termina su trabajo. Imagina que ordenas comida a domicilio: no te quedas esperando en la puerta, sino que das tu número de teléfono (el callback) para que te llamen cuando esté lista.
// Ejemplo básico de callback
function procesarDatos(datos, callback) {
console.log('Procesando datos...');
// Simular operación asíncrona
setTimeout(() => {
const resultado = datos.toUpperCase();
callback(resultado);
}, 1000);
}
// Usar el callback
procesarDatos('hola mundo', function(resultado) {
console.log('Resultado:', resultado); // "RESULTADO: HOLA MUNDO"
});
console.log('Esta línea se ejecuta inmediatamente');
El patrón Error-First Callback
Node.js establece una convención específica para los callbacks: el primer parámetro siempre debe ser un objeto de error, y los siguientes parámetros contienen los datos exitosos. Esta práctica se conoce como error-first callback y es fundamental en el ecosistema Node.js.
// Estructura del patrón error-first
function miCallback(error, datos) {
if (error) {
// Manejar el error
console.error('Error:', error.message);
return;
}
// Procesar los datos exitosos
console.log('Datos recibidos:', datos);
}
Ventajas del patrón error-first:
- Consistencia: Todas las APIs de Node.js siguen esta convención
- Manejo explícito de errores: Obliga a considerar casos de fallo
- Compatibilidad: Las librerías del ecosistema siguen el mismo patrón
Callbacks con módulos nativos
Los módulos nativos de Node.js utilizan callbacks para operaciones que pueden tomar tiempo, como leer archivos o realizar consultas de red.
Ejemplo con el sistema de archivos:
const fs = require('fs');
// Leer un archivo de forma asíncrona
fs.readFile('datos.txt', 'utf8', function(error, contenido) {
if (error) {
console.error('Error al leer archivo:', error.message);
return;
}
console.log('Contenido del archivo:');
console.log(contenido);
});
console.log('Esta línea se ejecuta antes que la lectura termine');
Manejo robusto de errores
El manejo adecuado de errores en callbacks requiere disciplina. Siempre debes verificar el parámetro error antes de procesar los datos.
const fs = require('fs');
function leerConfiguracion(callback) {
fs.readFile('config.json', 'utf8', function(error, contenido) {
if (error) {
// Error específico: archivo no encontrado
if (error.code === 'ENOENT') {
callback(new Error('Archivo de configuración no encontrado'));
return;
}
// Propagar otros errores
callback(error);
return;
}
// Intentar parsear JSON
try {
const configuracion = JSON.parse(contenido);
callback(null, configuracion);
} catch (parseError) {
callback(new Error('Configuración JSON inválida'));
}
});
}
// Uso con manejo completo de errores
leerConfiguracion(function(error, config) {
if (error) {
console.error('Error:', error.message);
return;
}
console.log('Configuración cargada:', config);
});
Callbacks anidados y el problema del Callback Hell
Cuando necesitas realizar múltiples operaciones asíncronas en secuencia, los callbacks pueden crear una estructura anidada difícil de leer, conocida como "callback hell" o "pyramid of doom".
const fs = require('fs');
// Ejemplo de callback hell - EVITAR
function procesarArchivos() {
fs.readFile('usuario.json', 'utf8', function(error1, datosUsuario) {
if (error1) {
console.error(error1);
return;
}
const usuario = JSON.parse(datosUsuario);
fs.readFile(`perfil_${usuario.id}.json`, 'utf8', function(error2, datosPerfil) {
if (error2) {
console.error(error2);
return;
}
const perfil = JSON.parse(datosPerfil);
fs.writeFile('resultado.json', JSON.stringify({
usuario: usuario,
perfil: perfil
}), function(error3) {
if (error3) {
console.error(error3);
return;
}
console.log('Archivos procesados exitosamente');
});
});
});
}
Mejorando la estructura con funciones nombradas
Una técnica efectiva para evitar el callback hell es extraer las funciones anónimas y darles nombres descriptivos.
const fs = require('fs');
function procesarArchivos() {
fs.readFile('usuario.json', 'utf8', manejarUsuario);
}
function manejarUsuario(error, datosUsuario) {
if (error) {
console.error('Error leyendo usuario:', error.message);
return;
}
const usuario = JSON.parse(datosUsuario);
fs.readFile(`perfil_${usuario.id}.json`, 'utf8', function(error, datosPerfil) {
manejarPerfil(error, datosPerfil, usuario);
});
}
function manejarPerfil(error, datosPerfil, usuario) {
if (error) {
console.error('Error leyendo perfil:', error.message);
return;
}
const perfil = JSON.parse(datosPerfil);
const resultado = { usuario, perfil };
fs.writeFile('resultado.json', JSON.stringify(resultado, null, 2), manejarEscritura);
}
function manejarEscritura(error) {
if (error) {
console.error('Error escribiendo resultado:', error.message);
return;
}
console.log('Archivos procesados exitosamente');
}
// Iniciar el proceso
procesarArchivos();
Patrones útiles con callbacks
Control de flujo paralelo: Cuando necesitas ejecutar múltiples operaciones que no dependen entre sí.
const fs = require('fs');
function leerArchivosParalelo(archivos, callback) {
let completados = 0;
let resultados = [];
let error = null;
if (archivos.length === 0) {
callback(null, []);
return;
}
archivos.forEach(function(archivo, indice) {
fs.readFile(archivo, 'utf8', function(err, contenido) {
if (err && !error) {
error = err;
callback(error);
return;
}
if (!error) {
resultados[indice] = contenido;
completados++;
if (completados === archivos.length) {
callback(null, resultados);
}
}
});
});
}
// Uso del control paralelo
leerArchivosParalelo(['archivo1.txt', 'archivo2.txt', 'archivo3.txt'],
function(error, contenidos) {
if (error) {
console.error('Error:', error.message);
return;
}
console.log('Todos los archivos leídos:');
contenidos.forEach((contenido, i) => {
console.log(`Archivo ${i + 1}: ${contenido.substring(0, 50)}...`);
});
}
);
Promesas
Las promesas representan una evolución natural desde los callbacks, proporcionando una forma más elegante y manejable de trabajar con operaciones asíncronas. Una promesa es un objeto que representa el resultado eventual de una operación asíncrona, ya sea exitosa o fallida.
Mientras que los callbacks pueden crear estructuras anidadas complejas, las promesas permiten encadenar operaciones de forma lineal y manejar errores de manera más centralizada. Una promesa puede estar en uno de tres estados: pendiente (pending), resuelta (fulfilled) o rechazada (rejected).
Estados de una promesa
Una promesa atraviesa diferentes estados durante su ciclo de vida:
- Pending: Estado inicial, la operación aún no se ha completado
- Fulfilled: La operación se completó exitosamente
- Rejected: La operación falló con un error
// Crear una promesa básica
const miPromesa = new Promise((resolve, reject) => {
// Simular operación asíncrona
setTimeout(() => {
const exito = Math.random() > 0.5;
if (exito) {
resolve('Operación completada exitosamente');
} else {
reject(new Error('La operación falló'));
}
}, 1000);
});
console.log(miPromesa); // Promise { <pending> }
Consumiendo promesas con then y catch
Las promesas se consumen utilizando los métodos then() para manejar el éxito y catch() para manejar los errores. Este patrón es más limpio que el manejo de errores con callbacks.
miPromesa
.then(resultado => {
console.log('Éxito:', resultado);
return resultado.toUpperCase(); // Valor para la siguiente promesa
})
.then(resultadoMayusculas => {
console.log('Transformado:', resultadoMayusculas);
})
.catch(error => {
console.error('Error capturado:', error.message);
})
.finally(() => {
console.log('Operación finalizada');
});
Promisificación de APIs basadas en callbacks
Node.js proporciona util.promisify() para convertir funciones basadas en callbacks al patrón de promesas, manteniendo la compatibilidad con APIs existentes.
const fs = require('fs');
const util = require('util');
// Convertir fs.readFile a promesa
const leerArchivo = util.promisify(fs.readFile);
// Uso con promesas
leerArchivo('config.json', 'utf8')
.then(contenido => {
const configuracion = JSON.parse(contenido);
console.log('Configuración cargada:', configuracion);
return configuracion;
})
.catch(error => {
if (error.code === 'ENOENT') {
console.error('Archivo no encontrado');
} else {
console.error('Error leyendo archivo:', error.message);
}
});
APIs nativas de promesas en Node.js
Las versiones modernas de Node.js incluyen APIs nativas basadas en promesas para módulos comunes, eliminando la necesidad de promisificación manual.
const fs = require('fs/promises');
const http = require('http');
// fs/promises proporciona métodos nativos con promesas
fs.readFile('datos.txt', 'utf8')
.then(contenido => {
console.log('Contenido:', contenido);
return fs.writeFile('copia.txt', contenido.toUpperCase());
})
.then(() => {
console.log('Archivo copiado y transformado');
})
.catch(error => {
console.error('Error en operación de archivos:', error.message);
});
Encadenamiento de promesas
El encadenamiento permite ejecutar operaciones secuenciales de forma legible, donde cada then() recibe el resultado del anterior.
const procesarDatos = (datos) => {
return Promise.resolve(datos)
.then(datos => {
console.log('Validando datos...');
if (!datos || datos.length === 0) {
throw new Error('Datos inválidos');
}
return datos;
})
.then(datos => {
console.log('Transformando datos...');
return datos.map(item => item.toUpperCase());
})
.then(datosTransformados => {
console.log('Guardando datos...');
return fs.writeFile('resultado.json', JSON.stringify(datosTransformados));
})
.then(() => {
console.log('Proceso completado exitosamente');
return 'Operación exitosa';
});
};
// Uso del encadenamiento
procesarDatos(['item1', 'item2', 'item3'])
.then(resultado => console.log(resultado))
.catch(error => console.error('Error en el proceso:', error.message));
Ejecutión en paralelo con Promise.all
Promise.all() ejecuta múltiples promesas concurrentemente y espera a que todas se resuelvan. Si cualquier promesa se rechaza, toda la operación falla.
const leerMultiplesArchivos = () => {
const promesasLectura = [
fs.readFile('archivo1.txt', 'utf8'),
fs.readFile('archivo2.txt', 'utf8'),
fs.readFile('archivo3.txt', 'utf8')
];
return Promise.all(promesasLectura);
};
leerMultiplesArchivos()
.then(contenidos => {
console.log('Todos los archivos leídos:');
contenidos.forEach((contenido, indice) => {
console.log(`Archivo ${indice + 1}: ${contenido.substring(0, 50)}...`);
});
})
.catch(error => {
console.error('Error leyendo archivos:', error.message);
});
Manejo resiliente con Promise.allSettled
Promise.allSettled() espera a que todas las promesas se completen, sin importar si fallan o tienen éxito, proporcionando información detallada sobre cada resultado.
const operacionesVariadas = [
fs.readFile('existe.txt', 'utf8'),
fs.readFile('no-existe.txt', 'utf8'),
Promise.resolve('Operación directa'),
Promise.reject(new Error('Error simulado'))
];
Promise.allSettled(operacionesVariadas)
.then(resultados => {
console.log('Resultados de todas las operaciones:');
resultados.forEach((resultado, indice) => {
if (resultado.status === 'fulfilled') {
console.log(`Operación ${indice}: Éxito -`, resultado.value.substring(0, 30));
} else {
console.log(`Operación ${indice}: Error -`, resultado.reason.message);
}
});
});
Primera respuesta con Promise.race
Promise.race() devuelve el resultado de la primera promesa que se resuelve o rechaza, útil para implementar timeouts o obtener la respuesta más rápida.
const operacionConTimeout = (datos) => {
const operacionPrincipal = new Promise(resolve => {
setTimeout(() => resolve(`Datos procesados: ${datos}`), 2000);
});
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout: operación muy lenta')), 1500);
});
return Promise.race([operacionPrincipal, timeout]);
};
operacionConTimeout('información importante')
.then(resultado => console.log('Resultado:', resultado))
.catch(error => console.error('Error o timeout:', error.message));
Creando promesas personalizadas
Puedes crear promesas personalizadas para encapsular lógica asíncrona compleja o adaptar APIs que no siguen el patrón estándar.
const simularApiExterna = (usuario) => {
return new Promise((resolve, reject) => {
// Simular latencia de red
const tiempoRespuesta = Math.random() * 2000;
setTimeout(() => {
if (usuario && usuario.trim()) {
const datosUsuario = {
id: Math.floor(Math.random() * 1000),
nombre: usuario,
activo: true,
fechaCreacion: new Date().toISOString()
};
resolve(datosUsuario);
} else {
reject(new Error('Usuario inválido'));
}
}, tiempoRespuesta);
});
};
// Uso de la promesa personalizada
simularApiExterna('juan_perez')
.then(usuario => {
console.log('Usuario obtenido:', usuario);
return simularApiExterna(usuario.nombre + '_backup');
})
.then(usuarioBackup => {
console.log('Usuario backup:', usuarioBackup);
})
.catch(error => {
console.error('Error en la cadena de usuarios:', error.message);
});
async / await
La sintaxis async/await representa la evolución más reciente en el manejo de asincronía en JavaScript, introduciendo una forma de escribir código asíncrono que se lee y mantiene como código síncrono. Esta característica, construida sobre las promesas, elimina la complejidad visual de los encadenamientos then() y proporciona un manejo de errores más intuitivo mediante bloques try/catch.
Cuando declaras una función como async, automáticamente devuelve una promesa, incluso si no lo especificas explícitamente. La palabra clave await pausa la ejecución de la función hasta que la promesa se resuelve, pero sin bloquear el hilo principal de Node.js.
Sintaxis básica de async/await
La declaración de funciones asíncronas utiliza la palabra clave async antes de la definición de la función, mientras que await se utiliza para esperar la resolución de promesas.
// Función async básica
async function obtenerDatos() {
const resultado = await fetch('https://api.ejemplo.com/datos');
const datos = await resultado.json();
return datos;
}
// Función async con arrow function
const procesarUsuario = async (id) => {
const usuario = await buscarUsuario(id);
const configuracion = await obtenerConfiguracion(usuario.tipo);
return { usuario, configuracion };
};
Conversión de promesas a async/await
Cualquier código basado en promesas puede convertirse fácilmente a la sintaxis async/await, manteniendo la misma funcionalidad con mejor legibilidad.
const fs = require('fs/promises');
// Versión con promesas
function leerArchivoPromesas(ruta) {
return fs.readFile(ruta, 'utf8')
.then(contenido => contenido.toUpperCase())
.then(contenidoMayusculas => {
console.log('Archivo procesado');
return contenidoMayusculas;
});
}
// Versión con async/await
async function leerArchivoAsync(ruta) {
const contenido = await fs.readFile(ruta, 'utf8');
const contenidoMayusculas = contenido.toUpperCase();
console.log('Archivo procesado');
return contenidoMayusculas;
}
Manejo de errores con try/catch
El manejo de errores en async/await utiliza la sintaxis familiar de try/catch, proporcionando un control más granular sobre diferentes tipos de errores.
const fs = require('fs/promises');
async function procesarConfiguracion(archivo) {
try {
const contenido = await fs.readFile(archivo, 'utf8');
const configuracion = JSON.parse(contenido);
// Validar configuración
if (!configuracion.version) {
throw new Error('Configuración inválida: falta versión');
}
return configuracion;
} catch (error) {
// Manejo específico por tipo de error
if (error.code === 'ENOENT') {
console.error('Archivo de configuración no encontrado');
return obtenerConfiguracionPorDefecto();
}
if (error instanceof SyntaxError) {
console.error('Error de formato JSON:', error.message);
throw new Error('Configuración corrupta');
}
// Re-lanzar otros errores
throw error;
}
}
Operaciones secuenciales vs paralelas
Una diferencia crucial en async/await es distinguir cuándo las operaciones deben ejecutarse secuencialmente versus en paralelo.
Ejecución secuencial:
async function procesarSecuencial() {
console.log('Iniciando proceso secuencial...');
// Cada operación espera a la anterior
const paso1 = await operacionLenta(1);
const paso2 = await operacionLenta(2);
const paso3 = await operacionLenta(3);
return { paso1, paso2, paso3 };
}
Ejecución paralela:
async function procesarParalelo() {
console.log('Iniciando proceso paralelo...');
// Iniciar todas las operaciones simultáneamente
const promesaPaso1 = operacionLenta(1);
const promesaPaso2 = operacionLenta(2);
const promesaPaso3 = operacionLenta(3);
// Esperar a que todas terminen
const paso1 = await promesaPaso1;
const paso2 = await promesaPaso2;
const paso3 = await promesaPaso3;
return { paso1, paso2, paso3 };
}
// O usando Promise.all para mayor claridad
async function procesarParaleloConAll() {
console.log('Iniciando proceso paralelo con Promise.all...');
const resultados = await Promise.all([
operacionLenta(1),
operacionLenta(2),
operacionLenta(3)
]);
return {
paso1: resultados[0],
paso2: resultados[1],
paso3: resultados[2]
};
}
Combinando async/await con Promise.all y Promise.allSettled
Async/await se integra perfectamente con los métodos estáticos de Promise para manejar múltiples operaciones asíncronas.
const fs = require('fs/promises');
async function analizarArchivos(rutas) {
try {
// Leer todos los archivos en paralelo
const contenidos = await Promise.all(
rutas.map(ruta => fs.readFile(ruta, 'utf8'))
);
// Procesar cada contenido
const estadisticas = contenidos.map(contenido => ({
lineas: contenido.split('\n').length,
caracteres: contenido.length,
palabras: contenido.split(/\s+/).length
}));
return estadisticas;
} catch (error) {
console.error('Error leyendo archivos:', error.message);
throw error;
}
}
// Versión resiliente que no falla si algunos archivos no existen
async function analizarArchivosResiliente(rutas) {
const resultados = await Promise.allSettled(
rutas.map(async ruta => {
const contenido = await fs.readFile(ruta, 'utf8');
return {
ruta,
lineas: contenido.split('\n').length,
caracteres: contenido.length
};
})
);
const exitosos = resultados
.filter(resultado => resultado.status === 'fulfilled')
.map(resultado => resultado.value);
const fallidos = resultados
.filter(resultado => resultado.status === 'rejected')
.map((resultado, indice) => ({
ruta: rutas[indice],
error: resultado.reason.message
}));
return { exitosos, fallidos };
}
Funciones generadoras asíncronas
Las funciones generadoras asíncronas combinan la potencia de los generadores con async/await para casos de uso avanzados como procesamiento de streams o iteración asíncrona.
async function* leerArchivosStream(rutas) {
for (const ruta of rutas) {
try {
const contenido = await fs.readFile(ruta, 'utf8');
yield { ruta, contenido, exito: true };
} catch (error) {
yield { ruta, error: error.message, exito: false };
}
}
}
// Uso del generador asíncrono
async function procesarArchivosStream() {
const rutas = ['archivo1.txt', 'archivo2.txt', 'archivo3.txt'];
for await (const resultado of leerArchivosStream(rutas)) {
if (resultado.exito) {
console.log(`Archivo ${resultado.ruta} procesado: ${resultado.contenido.length} caracteres`);
} else {
console.error(`Error en ${resultado.ruta}: ${resultado.error}`);
}
}
}
Patrones de retry y timeout con async/await
Implementar patrones de reintento y timeout es más legible con async/await que con promesas encadenadas.
// Función de retry con exponential backoff
async function operacionConReintentos(operacion, maxIntentos = 3) {
let ultimoError;
for (let intento = 1; intento <= maxIntentos; intento++) {
try {
const resultado = await operacion();
return resultado;
} catch (error) {
ultimoError = error;
if (intento === maxIntentos) {
throw new Error(`Operación falló después de ${maxIntentos} intentos: ${error.message}`);
}
// Esperar antes del siguiente intento (exponential backoff)
const tiempoEspera = Math.pow(2, intento - 1) * 1000;
console.log(`Intento ${intento} falló, reintentando en ${tiempoEspera}ms...`);
await new Promise(resolve => setTimeout(resolve, tiempoEspera));
}
}
}
// Implementar timeout con AbortController
async function operacionConTimeout(operacion, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const resultado = await operacion(controller.signal);
clearTimeout(timeoutId);
return resultado;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Operación cancelada por timeout (${timeoutMs}ms)`);
}
throw error;
}
}
// Ejemplo de uso combinado
async function obtenerDatosConfiables() {
const operacion = async (signal) => {
const response = await fetch('https://api.ejemplo.com/datos', { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
};
return await operacionConReintentos(
() => operacionConTimeout(operacion, 3000),
3
);
}
Top-level await en módulos ES
Las versiones modernas de Node.js soportan await a nivel superior en módulos ES, eliminando la necesidad de envolver código en funciones async.
// En un módulo ES (.mjs o con "type": "module" en package.json)
import fs from 'fs/promises';
// Await a nivel superior
const configuracion = await fs.readFile('config.json', 'utf8');
const datos = JSON.parse(configuracion);
console.log('Aplicación iniciada con configuración:', datos);
// Función que puede usar la configuración cargada
export async function inicializarServicio() {
const conexion = await establecerConexionDB(datos.database);
return new ServicioUsuarios(conexion);
}
Fuentes y referencias
Documentación oficial y recursos externos para profundizar en Node
Documentación oficial de Node
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Node es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Node
Explora más contenido relacionado con Node y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender el concepto y uso de callbacks en Node.js y el patrón error-first.
- Aprender a manejar errores y evitar el callback hell mediante funciones nombradas y patrones de control de flujo.
- Entender el funcionamiento de las promesas, sus estados y métodos para encadenar y manejar errores.
- Aplicar async/await para escribir código asíncrono más legible y manejable, incluyendo manejo de errores y ejecución paralela.
- Conocer patrones avanzados como retry, timeout, funciones generadoras asíncronas y top-level await en módulos ES.