🔄 Asincronía en Node.js
Callbacks, Promises, async/await, el event loop a fondo, nextTick vs setImmediate, streams, buffers, worker_threads y child_process con el orden exacto de ejecución.
Asincronía en Node.js
Node se construyó sobre una idea: nada debe bloquear el único hilo. Lectura de archivos, llamadas HTTP, consultas a bases de datos: todas se delegan y devuelven el control de inmediato. Quien domina este modelo escribe servicios que responden en milisegundos bajo carga; quien no, produce timeouts, carreras y callbacks imposibles de seguir. Este artículo recorre la evolución del modelo —callbacks → Promises → async/await— y desmonta el event loop con el orden exacto de ejecución.
Callbacks y el callback hell
Un callback es una función que se pasa a otra para ejecutarla cuando la operación asíncrona termina. Es el estilo original de Node:
import { readFile } from "node:fs";
readFile("a.txt", "utf8", (err, datos) => {
if (err) return console.error(err);
console.log(datos);
});
console.log("Esto se imprime ANTES de los datos"); // 🟢 orden clave
💡
readFiledevuelve el control al instante. Elconsole.logde abajo se ejecuta inmediatamente; el callback corre cuando el disco responde, no antes. Por eso el orden de salida es: primero el log, después los datos del archivo.
El problema: anidación sin fin
Operaciones dependientes obligan a anidar callbacks. Dos niveles se leen bien; cinco, no:
// callback hell: tres operaciones dependientes, cada vez más profundo
readFile("user.json", "utf8", (err, raw) => {
if (err) return console.error(err);
const user = JSON.parse(raw);
readFile(`perfiles/${user.id}.json`, "utf8", (err2, perfil) => {
if (err2) return console.error(err2);
db.buscar(user.id, (err3, pedidos) => {
if (err3) return console.error(err3);
console.log(user, perfil, pedidos); // el código útil queda enterrado
});
});
});
Los tres males del callback hell: anidación profunda, errores que se repiten a mano y pérdida del control de flujo. La solución: las Promises.
Promises
Una Promise es un objeto que representa un resultado futuro: pendiente, cumplida o rechazada. Nunca se usa directamente el valor: se encadenan .then / .catch:
function esperar(ms, falla = false) {
return new Promise((resolver, rechazar) => {
setTimeout(() => {
falla ? rechazar(new Error("Algo falló")) : resolver(`Pasaron ${ms} ms`);
}, ms);
});
}
esperar(500)
.then((resultado) => {
console.log(resultado);
return esperar(200); // encadenar: devolver otra Promise
})
.then((segundo) => console.log(segundo))
.catch((error) => console.error("Error:", error.message)) // un solo lugar
.finally(() => console.log("Siempre se ejecuta"));
| Método | Cuándo corre |
|---|---|
.then(cb) |
Promise resuelta (recibe el valor) |
.catch(cb) |
Promise rechazada, o error lanzado en cualquier .then anterior |
.finally(cb) |
Siempre, tras éxito o error (limpieza, sin valor) |
⚠️ El constructor
new Promise((resolver, rechazar) => ...)solo se usa cuando envuelves APIs viejas de callbacks. Si ya tienes una Promise (fetch,fs/promises), no la vuelvas a envolver; encadena directamente.
Combinar varias promesas
const pedidos = [
fetch("https://api.ejemplo.com/pedidos/1").then(r => r.json()),
fetch("https://api.ejemplo.com/pedidos/2").then(r => r.json()),
];
// Promise.all: TODAS o ninguna. Falla rápido ante el primer rechazo.
Promise.all(pedidos)
.then((lista) => console.log("Todos los pedidos:", lista.length))
.catch((error) => console.error("Algo falló:", error.message));
// Promise.allSettled: espera TODAS, cada una con su propio estado
Promise.allSettled(pedidos).then((resultados) =>
resultados.forEach((r) => console.log(r.status === "fulfilled" ? "OK" : "Falló"))
);
// Promise.race: gana la PRIMERA en resolver o rechazar (timeouts, por ejemplo)
const conTimeout = Promise.race([
fetch("https://api.ejemplo.com/pedidos/1"),
new Promise((_, rej) => setTimeout(() => rej(new Error("Timeout 2s")), 2000)),
]);
| Helper | Semántica | Úsalo para |
|---|---|---|
Promise.all |
Todas cumplidas, o falla con la primera | Reunir datos independientes que se necesitan juntos |
Promise.allSettled |
Todas terminan, cada una con su estado | Tolerar fallos parciales, reportes por elemento |
Promise.race |
La primera en terminar (cumplida o rechazada) | Timeouts, primer resultado disponible |
async / await
async/await es azúcar sintáctico sobre Promises: hace que el código asíncrono se lea como si fuera síncrono, sin cambiar nada del modelo.
async function procesarUsuario(id) {
try {
const res = await fetch(`https://api.ejemplo.com/usuarios/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const usuario = await res.json();
console.log("Usuario:", usuario.nombre);
return usuario;
} catch (error) {
console.error("No se pudo procesar:", error.message);
throw error;
}
}
procesarUsuario(42);
Reglas que de verdad importan:
async functionsiempre devuelve una Promise, aunque no tengaawait.awaitsolo vale dentro de una funciónasync.- Un
try/catchalrededor deawaitcaptura errores de la operación y de todo lo anterior en el bloque. - Para lanzar errores desde una función async, simplemente
throw: llegan como rechazo de la Promise.
// Paralelizar: NO encadenar awaits de operaciones independientes
async function cargarTodo() {
// ❌ lento: espera a antes de lanzar b
const a = await fetch(".../a").then(r => r.json());
const b = await fetch(".../b").then(r => r.json());
// ✅ rápido: lanzas ambas y esperas ambas
const [x, y] = await Promise.all([
fetch(".../a").then(r => r.json()),
fetch(".../b").then(r => r.json()),
]);
}
💡 La regla de oro del rendimiento async: los
awaitsecuenciales solo tienen sentido si la segunda operación necesita el resultado de la primera. Si son independientes,Promise.allen paralelo.
El event loop a fondo
Es el corazón del modelo. Un solo hilo ejecuta JavaScript, y todo lo demás (red, disco, timers) vive fuera, en la librería libuv. El event loop es un bucle de fases que reparte las tareas pendientes:
timers → I/O callbacks → idle (interno) → poll → check → close
▲ │
└─────────────────────────────────────────────────────┘
(las microtasks — nextTick y Promises — se drenan entre fases)
Tres piezas que explican casi todos los “¿por qué se ejecutó esto antes?”:
| Pieza | Qué contiene | Regla |
|---|---|---|
| Call stack | Las funciones que se están ejecutando ahora | Síncrono, LIFO, se vacía antes de mirar colas |
| Task queue (macrotasks) | Callbacks de timers, I/O, eventos | Se ejecutan uno por fase |
| Microtask queue | Promises (.then, await), queueMicrotask, process.nextTick |
Se drena entero antes de pasar a la siguiente fase |
La regla que domina todo: el call stack se vacía primero → luego se drenan TODAS las microtasks → recién entonces sigue con la siguiente macrotask.
console.log("1: síncrono");
setTimeout(() => console.log("4: macrotask (timer)"), 0);
Promise.resolve().then(() => console.log("3: microtask (Promise)"));
queueMicrotask(() => console.log("2: microtask (queueMicrotask)"));
console.log("5: síncrono, después de todo");
// Salida EXACTA:
// 1: síncrono
// 5: síncrono, después de todo
// 2: microtask (queueMicrotask)
// 3: microtask (Promise)
// 4: macrotask (timer)
⚠️ Tienes que ver el orden de arriba para creértelo: los síncronos salen primero (1 y 5), luego todas las microtasks (2 y 3, en orden de encolado), y al final la macrotask (4), aunque
setTimeoutse haya lanzado antes que la Promise. El0msde un timer es “lo antes posible en la fase de timers”, no “ya”.
process.nextTick vs setImmediate vs setTimeout
Tres formas de programar trabajo diferido, cada una con su momento:
console.log("A: inicio");
process.nextTick(() => console.log("C: nextTick (microtask prioritaria)"));
Promise.resolve().then(() => console.log("D: microtask Promise"));
setTimeout(() => console.log("F: timer (macrotask)"), 0);
setImmediate(() => console.log("E: check (macrotask, fase check)"));
console.log("B: fin del síncrono");
// Salida típica en el event loop principal:
// A → B (síncrono) → C (nextTick) → D (Promise)
// → E (check, suele ganar a F) → F (timer)
| Mecanismo | Cola | Cuándo corre | Para qué |
|---|---|---|---|
process.nextTick |
Microtask, antes que Promises | Tras la operación actual, antes de cualquier I/O | Correcciones internas de APIs; casi nunca en código de aplicación |
Promise |
Microtask | Tras el síncrono actual | La mayoría del trabajo diferido |
setImmediate |
Macrotask (fase check) | Una iteración completa después | “Después de todo el I/O pendiente” |
setTimeout(0) |
Macrotask (fase timers) | En la fase de timers de la próxima iteración | Latencia mínima (timer vence) |
💡 No hay “regla segura” entre
setImmediateysetTimeout(0): depende del contexto. Dentro de un callback de I/O,setImmediatecorre antes (el loop ya pasó timers); en el arranque,setTimeoutpuede ganar. La moraleja: si quieres “después del I/O”, usasetImmediate, nosetTimeout(0).
I/O no bloqueante con ejemplos reales
La prueba definitiva del modelo: lanzar 10 lecturas de disco en paralelo y medir que no se encadenan.
import { readFile } from "node:fs/promises";
const archivos = Array.from({ length: 10 }, (_, i) => `datos/f${i}.txt`);
const inicio = Date.now();
const resultados = await Promise.all(
archivos.map((f) => readFile(f, "utf8"))
);
console.log(`Leídos ${resultados.length} archivos en ${Date.now() - inicio} ms`);
Con un único hilo y operaciones no bloqueantes, 10 lecturas simultáneas tardan casi lo mismo que una: el disco es el recurso compartido, no el hilo. Si fueran síncronas (readFileSync), se sumarían los tiempos uno tras otro.
Streams: procesar sin cargar todo en memoria
Leer un archivo de 2 GB con readFile vuelca 2 GB en RAM. Los streams procesan de a pedazos (chunks) y son la base de las transferencias grandes.
| Tipo | Dirección | Ejemplo |
|---|---|---|
Readable |
Fuente → consumidor | createReadStream |
Writable |
Consumidor → destino | createWriteStream, res (HTTP) |
Transform |
Lee, transforma y escribe | gzip, crypto |
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
// Copiar y comprimir un archivo grande sin cargarlo en memoria
createReadStream("grande.log") // Readable
.pipe(createGzip()) // Transform
.pipe(createWriteStream("grande.log.gz")); // Writable
// Consumir un stream evento a evento
createReadStream("grande.log", { encoding: "utf8" })
.on("data", (chunk) => process.stdout.write(`[${chunk.length} bytes]`))
.on("end", () => console.log("\nTerminado"));
Backpressure
Si el consumidor es más lento que el productor, los chunks se acumulan en memoria. El mecanismo de backpressure lo evita: .pipe() pausa la fuente cuando el destino no puede absorber más y reanuda cuando puede. Hazlo manual:
import { createReadStream, createWriteStream } from "node:fs";
const fuente = createReadStream("grande.log");
const destino = createWriteStream("copia.log");
fuente.on("data", (chunk) => {
if (!destino.write(chunk)) {
fuente.pause(); // destino saturado: pausa la fuente
destino.once("drain", () => fuente.resume()); // avisará cuando drene
}
});
destino.on("finish", () => console.log("Copia completa"));
⚠️ Sin backpressure, el proceso se queda sin memoria bajo flujos grandes.
.pipe()ystream/promises(pipeline) lo gestionan por ti: prefiérelos a manipulardataa mano salvo que necesites control fino.
Buffers
Antes de los streams de texto, los datos viajan como Buffer: una secuencia de bytes en memoria.
const buf = Buffer.from("Hola Node", "utf8");
console.log(buf.length); // 9 bytes
console.log(buf.toString("utf8")); // "Hola Node"
console.log(buf.toString("hex")); // 486f6c61204e6f6465
// Buffer es mutable y de tamaño fijo
buf[0] = 0x68; // 0x68 = 'h'
console.log(buf.toString()); // "hola Node"
Los Buffer son lo que los streams emiten por defecto (antes de decodificar a string). Saber leer su toString("utf8") y su tamaño en bytes resuelve la mitad de los bugs de codificación.
worker_threads: paralelismo real
Para CPU intensivo, el hilo único se satura. worker_threads ejecuta código en hilos reales del sistema, comunicados por mensajes:
// worker.js
import { parentPort, workerData } from "node:worker_threads";
const numeros = workerData;
const suma = numeros.reduce((a, b) => a + b, 0);
parentPort.postMessage(suma); // devuelve el resultado al hilo principal
// main.js
import { Worker } from "node:worker_threads";
const worker = new Worker("./worker.js", {
workerData: Array.from({ length: 10_000_000 }, (_, i) => i),
});
worker.on("message", (suma) => console.log("Suma total:", suma));
worker.on("error", (err) => console.error("Worker falló:", err.message));
💡 Los datos entre hilos se copian (structured clone), salvo que uses
SharedArrayBufferpara memoria compartida. Empezar copiando es lo correcto: el paralelismo con copia ya da el salto de rendimiento en CPU-bound.
child_process: exec, spawn, fork
Para ejecutar programas externos (git, ffmpeg, un binario):
import { exec, spawn, fork } from "node:child_process";
// exec: salida completa de una vez (máx ~200 KB, útil para comandos cortos)
exec("git log --oneline -5", (err, stdout, stderr) => {
if (err) return console.error("Fallo:", err.message);
console.log(stdout);
});
// spawn: stream de salida, ideal para comandos largos y mucho output
const ffmpeg = spawn("ffmpeg", ["-i", "in.mp4", "out.webm"]);
ffmpeg.stdout.on("data", (c) => process.stdout.write(c));
ffmpeg.on("close", (code) => console.log("ffmpeg terminó:", code));
// fork: un hijo de Node que se comunica por mensajes (como worker_threads pero por IPC)
const hijo = fork("./calculador.js");
hijo.on("message", (m) => console.log("Del hijo:", m));
hijo.send({ op: "sumar", a: 2, b: 3 });
exec |
spawn |
fork |
|
|---|---|---|---|
| Salida | Completa, en memoria | Streaming por eventos | Mensajes JSON |
| Uso típico | Comandos cortos | Procesos largos, binarios | Hijos de Node con IPC |
⚠️
execcon entrada de usuario es una puerta para inyección de comandos: nunca concatenes input en la cadena del comando. Si el argumento viene de datos externos, usaspawncon el array de argumentos (no pasa por el shell) y valida lo que entra.
Ejemplo real: el orden exacto de ejecución
import { setImmediate as setImm } from "node:timers";
console.log("1: síncrono");
setTimeout(() => console.log("2: timer"), 0);
setImm(() => console.log("3: setImmediate"));
Promise.resolve()
.then(() => console.log("4: microtask 1"))
.then(() => console.log("5: microtask 2"));
process.nextTick(() => console.log("6: nextTick"));
queueMicrotask(() => console.log("7: queueMicrotask"));
console.log("8: síncrono final");
// Salida EXACTA:
// 1: síncrono
// 8: síncrono final
// 6: nextTick ← las microtasks "especiales" van primero
// 4: microtask 1 ← luego las de Promises, en orden
// 5: microtask 2
// 7: queueMicrotask
// 3: setImmediate ← fase check (suele ganar en el arranque)
// 2: timer ← fase timers de la iteración siguiente
// Y con I/O real: el orden cambia porque el loop ya está en otra fase
import { readFile } from "node:fs";
import { setImmediate as setImm } from "node:timers";
readFile("archivo.txt", "utf8", () => {
setTimeout(() => console.log("1: timer"), 0);
setImm(() => console.log("2: setImmediate"));
process.nextTick(() => console.log("3: nextTick"));
Promise.resolve().then(() => console.log("4: Promise"));
// Dentro de un callback de I/O (fase poll), el orden es determinista:
// 3: nextTick → 4: Promise → 2: setImmediate → 1: timer
});
💡 Si en el arranque
setImmediateysetTimeoutse “empujan” en el orden, dentro de un callback de I/O el resultado es determinista:nextTickyPromiseprimero (microtasks), luegosetImmediate, ysetTimeoutal final. Esa fase check es la pieza clave del acertijo.
Para profundizar
- Node.js — The Node.js Event Loop: la guía oficial que explica cada fase y la diferencia entre
nextTick,setImmediatey timers. - Node.js — Asynchronous flow control: callbacks, Promises y cómo escribir flujo asíncrono correcto.
- Node.js — Streams: cómo funcionan Readable, Writable, Transform y backpressure.
- Node.js — Worker threads: referencia del módulo para paralelismo con CPU-bound.
- Ruta completa: Backend con Node.