🔎 Buscar

🔄 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.

Wiki / Apuntes📖 Contenido

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

💡 readFile devuelve el control al instante. El console.log de 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 function siempre devuelve una Promise, aunque no tenga await.
  • await solo vale dentro de una función async.
  • Un try/catch alrededor de await captura 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 await secuenciales solo tienen sentido si la segunda operación necesita el resultado de la primera. Si son independientes, Promise.all en 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 setTimeout se haya lanzado antes que la Promise. El 0ms de 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 setImmediate y setTimeout(0): depende del contexto. Dentro de un callback de I/O, setImmediate corre antes (el loop ya pasó timers); en el arranque, setTimeout puede ganar. La moraleja: si quieres “después del I/O”, usa setImmediate, no setTimeout(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() y stream/promises (pipeline) lo gestionan por ti: prefiérelos a manipular data a 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 SharedArrayBuffer para 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

⚠️ exec con 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, usa spawn con 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 setImmediate y setTimeout se “empujan” en el orden, dentro de un callback de I/O el resultado es determinista: nextTick y Promise primero (microtasks), luego setImmediate, y setTimeout al final. Esa fase check es la pieza clave del acertijo.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.