En el Módulo 1 usaste Node.js como una caja negra: escribiste node src/catalogo.js y aparecieron eventos por pantalla. Funcionó, pero no sabes qué hay dentro de esa caja. Y esa ignorancia tiene consecuencias muy concretas: no sabrías explicar por qué un servidor Node aguanta diez mil conexiones simultáneas con un solo proceso, por qué leer un fichero grande no bloquea a nadie pero calcular un hash sí, ni por qué el consejo "no bloquees el hilo principal" se repite hasta la saciedad en toda la documentación.

Esta lección abre la caja. Vamos a ver las capas reales que componen Node.js —tu JavaScript, la biblioteca estándar, los bindings, V8 y libuv—, qué hace exactamente cada una, y el matiz que casi todo el mundo repite mal: Node.js no es de un solo hilo del todo. Hay un hilo principal donde vive tu código y un grupo de hilos auxiliares donde ocurren cosas que tú nunca ves. Al terminar sabrás qué operaciones van a cada sitio, podrás medirlo con experimentos ejecutables, y entenderás por qué bloquear el hilo principal es el pecado capital del desarrollo en Node.

Es la base sobre la que se apoya todo lo demás del módulo. Sin ella, el bucle de eventos de la próxima lección es magia; con ella, es mecánica.

Contenido

  1. La pila de Node.js capa por capa
  2. V8: el motor que ejecuta tu JavaScript
  3. libuv: el motor que hace posible la asincronía
  4. El matiz clave: Node no es de un solo hilo del todo
  5. Qué va al thread pool y qué no
  6. Demostración medible: los cuatro hilos y el quinto que espera
  7. El pecado capital: bloquear el hilo principal
  8. El ciclo de vida de un proceso Node
  9. Inspeccionar el proceso: process.version y process.memoryUsage()

  1. La pila de Node.js capa por capa

Cuando ejecutas node src/catalogo.js, ese comando pone en marcha un programa escrito en C++ que contiene, empaquetados dentro del mismo binario, un motor de JavaScript y una biblioteca de entrada/salida asíncrona. Tu fichero .js es simplemente el dato de entrada de ese programa.

Estas son las capas, de arriba abajo:

flowchart TD
    A["<b>Tu código JavaScript</b><br/>src/catalogo.js, src/dominio/evento.js"]
    B["<b>Biblioteca estándar de Node</b> (JavaScript)<br/>fs, http, path, events, crypto...<br/>lib/*.js dentro del binario"]
    C["<b>Bindings</b> (C++)<br/>El puente entre JavaScript y C++<br/>node_file.cc, node_http_parser.cc..."]
    D1["<b>V8</b> (C++)<br/>Ejecuta JavaScript:<br/>compila, gestiona memoria"]
    D2["<b>libuv</b> (C)<br/>Bucle de eventos, thread pool,<br/>E/S asíncrona del sistema"]
    D3["<b>Otras bibliotecas</b> (C/C++)<br/>OpenSSL, zlib, c-ares, llhttp"]
    E["<b>Sistema operativo</b><br/>epoll (Linux) · kqueue (macOS) · IOCP (Windows)"]

    A --> B
    B --> C
    C --> D1
    C --> D2
    C --> D3
    D1 --> E
    D2 --> E
    D3 --> E

Vamos capa por capa con un ejemplo concreto: la llamada fs.readFile('datos/eventos.json', callback) que escribirás de verdad en el Módulo 3.

Capa Qué es Qué hace con fs.readFile
Tu código Los .js que escribes Llama a fs.readFile con una ruta y un callback
Biblioteca estándar Módulos de Node escritos en JavaScript, precompilados dentro del binario Valida los argumentos, normaliza la ruta, prepara el objeto de petición y llama al binding
Bindings Código C++ que expone funciones nativas a JavaScript Traduce los valores de JavaScript a tipos de C++ y llama a uv_fs_read
libuv Biblioteca C de E/S asíncrona Encola la lectura en su thread pool, y cuando termina, avisa
V8 Motor de JavaScript Ejecuta tu callback cuando Node se lo pide
Sistema operativo El núcleo Hace la lectura real del disco

Dos ideas importantes de esta tabla:

  • Buena parte de Node.js está escrita en JavaScript, no en C++. Los módulos fs, http, path, events, stream… son ficheros .js incrustados en el binario. Puedes leerlos: están en el repositorio de Node en la carpeta lib/. Esto significa que la biblioteca estándar de Node es, en gran medida, código como el tuyo.
  • Los bindings son la frontera. Todo lo que JavaScript no puede hacer por sí mismo —abrir un socket, leer un fichero, cifrar— cruza esa frontera hacia C++. Cruzarla tiene un coste pequeño pero no nulo, y por eso interesa hacer pocas llamadas grandes en lugar de muchas pequeñas.

Comprobación rápida. Node expone qué versiones de cada dependencia lleva dentro. Ejecuta esto en tu terminal:

node -p "JSON.stringify(process.versions, null, 2)"

Verás algo parecido a { "node": "22.11.0", "v8": "12.4.254.21", "uv": "1.48.0", "openssl": "3.0.15", "zlib": "1.3.1", ... }. Cada una de esas líneas es una de las bibliotecas del diagrama.

  1. V8: el motor que ejecuta tu JavaScript

V8 es el motor de JavaScript de Google, el mismo que usa Chrome. Su trabajo es uno solo: convertir tu código JavaScript en instrucciones de máquina y ejecutarlas. V8 no sabe nada de ficheros, ni de red, ni de temporizadores. Eso no es parte de JavaScript; lo pone Node.

V8 hace tres cosas que te afectan directamente.

2.1 Compilación JIT (just-in-time)

JavaScript no se interpreta línea a línea como en los años noventa. V8 usa una tubería de varias etapas:

  1. Análisis y bytecode. Un intérprete llamado Ignition convierte tu código en un bytecode compacto y lo ejecuta. Es rápido de arrancar pero moderado en velocidad.
  2. Observación. Mientras se ejecuta, V8 registra qué funciones se llaman mucho y con qué tipos de datos.
  3. Optimización. Un compilador optimizador (TurboFan) toma las funciones "calientes" y genera código máquina especializado para los tipos que ha observado.
  4. Desoptimización. Si de pronto llamas a esa función con un tipo distinto del previsto, V8 descarta el código optimizado y vuelve al bytecode.

La consecuencia práctica para ti: la coherencia de tipos importa. Una función que a veces recibe un número y a veces una cadena es más lenta que una que siempre recibe un número.

// src/laboratorio/tipos.js
// Bien: precioCentimos es SIEMPRE un entero.
function calcularTotalCentimos(sesiones) {
  return sesiones.reduce((total, s) => total + s.vendidas * s.precioCentimos, 0);
}

// Mal: si precioCentimos a veces llega como cadena ('2500'), V8 no puede
// especializar la funcion y ademas el '+' concatena en lugar de sumar.

Esto es otra razón —además de la exactitud del dinero— para nuestra convención de guardar los importes siempre como enteros de céntimos: cada campo tiene un único tipo durante toda su vida.

2.2 El montículo (heap) y la memoria

V8 gestiona la memoria de todos tus objetos en una zona llamada montículo, dividida en dos regiones:

Región Qué contiene Cómo se limpia
Nueva generación (new space) Objetos recién creados. Es pequeña (unos pocos MB) Recolección muy frecuente y muy rápida (scavenge)
Vieja generación (old space) Objetos que han sobrevivido a varias recolecciones Recolección menos frecuente y más costosa (mark-sweep-compact)

La hipótesis en la que se basa este diseño es que la mayoría de los objetos mueren jóvenes. En Escena Viva, el objeto temporal que creas para formatear una fecha muere en microsegundos; el array catalogo vive todo el proceso. Separarlos permite limpiar lo efímero muy barato.

2.3 Recolección de basura

En JavaScript no liberas memoria a mano: V8 detecta qué objetos ya no son alcanzables desde ninguna variable viva y recupera su espacio. Lo que debes saber:

  • La recolección de basura pausa la ejecución de tu JavaScript. Las pausas de la nueva generación son de microsegundos y no se notan; las de la vieja generación pueden ser de milisegundos y sí se notan en un servidor cargado.
  • El montículo tiene un límite. Por defecto ronda los 4 GB en versiones modernas de 64 bits, y se ajusta con node --max-old-space-size=2048. Si lo superas, el proceso muere con JavaScript heap out of memory.
  • Se pueden tener fugas de memoria en JavaScript. No por olvidar liberar, sino por seguir refiriéndote a cosas que ya no necesitas: una caché que crece sin límite, un array que solo acumula, o —caso muy típico y que veremos en la lección de Eventos y EventEmitter— oyentes que se registran y nunca se desregistran.

  1. libuv: el motor que hace posible la asincronía

Si V8 aporta el "ejecutar JavaScript", libuv aporta el "hacerlo sin esperar". Es una biblioteca escrita en C, nacida precisamente para Node.js, cuyo objetivo es ofrecer una única API asíncrona multiplataforma por encima de mecanismos de sistema operativo que son completamente distintos entre sí.

libuv tiene tres responsabilidades:

  1. El bucle de eventos (event loop). El ciclo infinito que pregunta "¿hay algo terminado? ¿hay algún callback que ejecutar?" y va procesando el trabajo pendiente. Es tan central que le dedicamos la lección siguiente entera, El Bucle de Eventos (Event Loop).
  2. La E/S asíncrona del sistema operativo. Para red, libuv usa el mecanismo nativo de notificación de cada plataforma: epoll en Linux, kqueue en macOS y BSD, event ports en Solaris e IOCP en Windows. Con estos mecanismos, un solo hilo puede vigilar miles de sockets a la vez sin consumir un hilo por conexión.
  3. La cola de trabajo y el thread pool. Para las operaciones que el sistema operativo no ofrece de forma asíncrona fiable, libuv mantiene un grupo de hilos reales a los que envía el trabajo. Es la parte que más malentendidos genera y la que veremos en detalle a continuación.
flowchart LR
    JS["Tu JavaScript<br/>(hilo principal)"] -->|"peticion asincrona"| UV["libuv"]
    UV -->|"red, sockets, tuberias"| OS["epoll / kqueue / IOCP<br/>(sin hilos extra)"]
    UV -->|"ficheros, DNS, crypto, zlib"| TP["Thread pool<br/>4 hilos por defecto"]
    OS -->|"listo"| CB["Cola de callbacks"]
    TP -->|"listo"| CB
    CB -->|"el bucle los ejecuta"| JS

Lee el diagrama en voz alta una vez: tu código pide algo, libuv decide por qué camino resolverlo, el trabajo se hace fuera de tu hilo, y cuando termina, el resultado vuelve a tu hilo en forma de callback. Ese viaje de ida y vuelta es toda la asincronía de Node.

  1. El matiz clave: Node no es de un solo hilo del todo

La frase "Node.js es de un solo hilo" se repite en todas partes y es medio verdad. La versión precisa es:

Tu código JavaScript se ejecuta en un único hilo. El trabajo de entrada/salida no.

Si abres el monitor de procesos mientras corre un programa Node, verás que el proceso tiene varios hilos: el principal, los cuatro del thread pool, y algunos más de V8 para la recolección de basura y la compilación en segundo plano.

Compruébalo tú mismo:

# Arranca un proceso Node que no termine
node -e "setInterval(() => {}, 1000)" &

# Linux: contar los hilos del proceso
ps -o nlwp -p $!
# NLWP
#   11        <- once hilos, no uno

En macOS puedes usar ps -M <pid>; en Windows, el Administrador de tareas con la columna "Subprocesos" activada.

Lo que esto significa en la práctica:

Afirmación ¿Cierta? Matiz
"Node ejecuta mi JavaScript en un solo hilo" Sí Dos funciones tuyas nunca corren a la vez. Por eso no hay condiciones de carrera sobre variables
"Node es monohilo, punto" No El proceso tiene un thread pool y hilos internos de V8
"Node puede aprovechar los 8 núcleos de mi máquina" Parcialmente La E/S sí; tu JavaScript no. Para eso están cluster y worker_threads del Módulo 10
"Si bloqueo el hilo principal, solo se ralentiza esa petición" No Se bloquean todas las peticiones de ese proceso

La ventaja de que tu JavaScript sea monohilo es enorme y a menudo se olvida: no existen condiciones de carrera dentro de un bloque de código síncrono. Cuando en Escena Viva escribas sesion.vendidas += cantidad, nadie puede colarse entre la lectura y la escritura. En Java o en Go tendrías que pensar en un cerrojo; aquí no. El precio de esa tranquilidad es que tú eres responsable de devolver el control rápido.

  1. Qué va al thread pool y qué no

Esta es probablemente la tabla más útil de la lección. El thread pool de libuv tiene 4 hilos por defecto y se configura con la variable de entorno UV_THREADPOOL_SIZE (máximo 1024 en versiones modernas).

Operación Camino Por qué
Sockets TCP/UDP, servidor y cliente HTTP epoll/kqueue/IOCP El sistema operativo ya ofrece notificación asíncrona nativa para red
Tuberías (pipes) y procesos hijo epoll/kqueue/IOCP Igual que la red
fs.readFile, fs.writeFile, fs.stat… Thread pool La E/S de ficheros asíncrona del sistema es inconsistente entre plataformas; libuv la simula con hilos
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (versión asíncrona) Thread pool Son cálculo puro de CPU; en el hilo principal lo bloquearían
zlib.gzip, zlib.deflate (versiones asíncronas) Thread pool Compresión: también cálculo intensivo
dns.lookup Thread pool Usa getaddrinfo del sistema, que es una llamada bloqueante
dns.resolve, dns.resolve4… epoll/kqueue/IOCP Usa la biblioteca c-ares, que habla DNS por red de forma asíncrona
setTimeout, setInterval, setImmediate Ni uno ni otro Los gestiona el propio bucle de eventos, sin hilos ni E/S
Tu bucle for sobre 3000 sesiones Hilo principal Es JavaScript. Nadie lo va a mover a otro sitio

Fíjate en el detalle de DNS: dns.lookup y dns.resolve no son sinónimos. dns.lookup es lo que usa por debajo http.request cuando le das un nombre de host, y consume un hilo del thread pool. Es un clásico: una aplicación que hace muchas peticiones HTTP salientes agota los cuatro hilos con resoluciones de DNS y, de pronto, las lecturas de fichero se vuelven lentas sin motivo aparente.

Y fíjate en la última fila, porque es la que resume la lección: para el cálculo en JavaScript no hay salida. El thread pool solo sirve al código nativo de Node. Si tú escribes un bucle que tarda dos segundos, esos dos segundos los paga el hilo principal.

  1. Demostración medible: los cuatro hilos y el quinto que espera

Basta de teoría. Vamos a medir el thread pool. Usaremos crypto.pbkdf2, una función de derivación de claves (la misma familia que usaremos en el Módulo 8 para las contraseñas) que consume CPU de forma intensiva y que libuv envía al thread pool.

Crea src/laboratorio/thread-pool.js:

// src/laboratorio/thread-pool.js
// Demuestra que el thread pool de libuv tiene 4 hilos por defecto.
// Lanzamos N tareas identicas de CPU y medimos cuando termina cada una.

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

// Cuantas tareas lanzar: por defecto 5, o el primer argumento de la linea de comandos.
const TAREAS = Number(process.argv[2]) || 5;

// Parametros de pbkdf2: contrasena, sal, iteraciones, longitud, algoritmo.
// 200000 iteraciones tardan del orden de 100-200 ms en una maquina normal.
const ITERACIONES = 200000;

const inicio = Date.now();

console.log(`Thread pool: ${process.env.UV_THREADPOOL_SIZE || 4} hilos`);
console.log(`Lanzando ${TAREAS} tareas de CPU...`);
console.log('');

for (let i = 1; i <= TAREAS; i++) {
  crypto.pbkdf2('entrada-escena-viva', 'sal', ITERACIONES, 64, 'sha512', () => {
    const transcurrido = Date.now() - inicio;
    console.log(`Tarea ${i} terminada en ${transcurrido} ms`);
  });
}

Ejecuta:

node src/laboratorio/thread-pool.js 5

Salida típica (los milisegundos variarán según tu máquina, pero el patrón será el mismo):

Thread pool: 4 hilos
Lanzando 5 tareas de CPU...

Tarea 1 terminada en 168 ms
Tarea 2 terminada en 171 ms
Tarea 3 terminada en 172 ms
Tarea 4 terminada en 175 ms
Tarea 5 terminada en 338 ms      <-- el doble

Lee bien ese resultado, porque contiene toda la lección:

  • Las cuatro primeras tareas terminan casi a la vez, alrededor de 170 ms. Se han ejecutado en paralelo de verdad, cada una en un hilo del thread pool, en cuatro núcleos distintos de tu procesador.
  • La quinta tarda aproximadamente el doble. No había un quinto hilo libre: ha tenido que esperar en cola a que uno de los cuatro se liberara.

Ahora sube el tamaño del thread pool y vuelve a ejecutarlo:

# Linux y macOS
UV_THREADPOOL_SIZE=5 node src/laboratorio/thread-pool.js 5

# Windows (PowerShell)
# $env:UV_THREADPOOL_SIZE=5; node src/laboratorio/thread-pool.js 5
Thread pool: 5 hilos
Lanzando 5 tareas de CPU...

Tarea 1 terminada en 178 ms
Tarea 2 terminada en 181 ms
Tarea 3 terminada en 182 ms
Tarea 4 terminada en 184 ms
Tarea 5 terminada en 185 ms      <-- ahora tambien en paralelo

Las cinco terminan a la vez. Acabas de comprobar experimentalmente el tamaño del thread pool y su efecto.

Cuidado con la tentación de subirlo mucho. UV_THREADPOOL_SIZE=128 no multiplica el rendimiento por 32: si tu máquina tiene 8 núcleos, 128 hilos compiten por los mismos 8 núcleos y añaden coste de cambio de contexto. La regla razonable es acercarlo al número de núcleos cuando tu aplicación hace mucha E/S de ficheros o mucho crypto, y medir siempre antes y después. La variable debe estar puesta antes de arrancar el proceso: cambiarla desde dentro con process.env.UV_THREADPOOL_SIZE = 8 no tiene efecto, porque el pool ya se ha creado.

  1. El pecado capital: bloquear el hilo principal

Ahora el experimento complementario, y el más importante de la lección. ¿Qué pasa si el trabajo pesado lo haces en JavaScript en lugar de delegarlo?

Crea src/laboratorio/bloqueo.js:

// src/laboratorio/bloqueo.js
// Demuestra que un bucle sincrono congela TODO el proceso.

const inicio = Date.now();

// Un temporizador que deberia dispararse cada 100 ms.
// Lo usamos como "electrocardiograma" del proceso.
const latido = setInterval(() => {
  console.log(`  latido a los ${Date.now() - inicio} ms`);
}, 100);

// Paramos el experimento a los 2 segundos de reloj.
setTimeout(() => {
  clearInterval(latido);
  console.log('Fin.');
}, 2000);

// Tarea sincrona pesada: sumar 3.000 millones de veces.
// Simula un calculo mal planteado sobre el catalogo.
function bloquearDurante(ms) {
  const limite = Date.now() + ms;
  while (Date.now() < limite) {
    // Bucle vacio a proposito: representa cualquier calculo largo.
  }
}

// A los 300 ms, bloqueamos el hilo principal durante un segundo entero.
setTimeout(() => {
  console.log('>> Empieza el bloqueo de 1000 ms');
  bloquearDurante(1000);
  console.log('>> Termina el bloqueo');
}, 300);

Ejecuta node src/laboratorio/bloqueo.js:

  latido a los 103 ms
  latido a los 205 ms
>> Empieza el bloqueo de 1000 ms
>> Termina el bloqueo
  latido a los 1306 ms      <-- el latido de los 400, 500, 600... nunca llego
  latido a los 1406 ms
  latido a los 1507 ms
  ...
Fin.

Mira el hueco: entre los 205 ms y los 1306 ms no hubo ni un solo latido. Durante un segundo entero el proceso estuvo vivo pero completamente sordo. Los temporizadores no se dispararon, y si hubiera sido un servidor HTTP, ninguna petición se habría atendido: se habrían quedado en la cola del sistema operativo esperando a que el hilo principal volviera a estar libre.

Traducido a Escena Viva: si un usuario pide un informe que tarda un segundo en calcularse de forma síncrona, los otros trescientos usuarios que están comprando entradas en ese momento esperan también ese segundo. No es que su petición sea más lenta; es que el servidor entero se ha detenido.

Esta es la diferencia fundamental con el modelo de un hilo por petición que viste en la primera lección: allí, una petición lenta perjudica solo a quien la hizo. Aquí perjudica a todos. Es el precio de la eficiencia del modelo de Node, y explica la regla que gobernará el resto del curso:

Todo lo que hagas en el hilo principal debe durar poco. Lo que dure mucho, delégalo.

Las formas de delegar, en orden de preferencia:

Situación Solución Dónde se ve
E/S: ficheros, red, base de datos Usar la versión asíncrona de la API, nunca la ...Sync Módulos 3, 4 y 7
Cálculo pesado que Node ya ofrece nativo crypto, zlib en su versión asíncrona (van al thread pool) Este mismo módulo
Cálculo pesado en JavaScript propio worker_threads: un hilo aparte con su propio V8 Módulo 10
Aprovechar todos los núcleos para atender tráfico cluster: varios procesos Node repartiéndose el puerto Módulo 10
Trabajo que puede esperar (correos, PDF) Sacarlo del proceso: una cola de trabajos Módulo 10

Es exactamente la promesa que se hizo en la lección ¿Qué es Node.js? cuando dijimos que el cálculo intensivo de CPU era el punto débil de Node y que el Módulo 10 lo resolvería. Ahora ya sabes por qué es el punto débil.

  1. El ciclo de vida de un proceso Node

Queda una pregunta que quizá no te habías hecho: cuando ejecutaste node src/catalogo.js, el programa imprimió el catálogo y terminó solo. Pero si ejecutas node -e "setInterval(() => {}, 1000)", el proceso no termina nunca. ¿Quién decide?

El ciclo de vida de un proceso Node tiene estas etapas:

flowchart TD
    A["1. Arranque<br/>El binario inicializa V8 y libuv"] --> B["2. Bootstrap<br/>Se prepara process, console,<br/>y los modulos internos"]
    B --> C["3. Ejecucion del script<br/>Se ejecuta tu fichero de arriba abajo,<br/>de forma sincrona y completa"]
    C --> D{"4. Quedan tareas<br/>pendientes?"}
    D -->|"Si"| E["5. Bucle de eventos<br/>Espera, ejecuta callbacks,<br/>vuelve a preguntar"]
    E --> D
    D -->|"No"| F["6. Salida<br/>Se emite 'exit' y el proceso<br/>devuelve process.exitCode"]

La clave está en el rombo del paso 4. libuv lleva una cuenta de referencias de las tareas activas: temporizadores programados, sockets abiertos, operaciones de fichero en curso, servidores escuchando. Mientras esa cuenta sea mayor que cero, el bucle sigue girando. Cuando llega a cero, no queda nada que pueda ocurrir jamás, así que Node cierra ordenadamente y devuelve el control a la terminal.

Esto explica los dos casos:

// src/laboratorio/ciclo-vida.js

console.log('1. Empieza el script');

// Esto AÑADE una referencia: hay una tarea futura pendiente.
setTimeout(() => {
  console.log('3. Temporizador de 500 ms disparado');
  // Al ejecutarse, la referencia se libera. Ya no queda nada -> salida.
}, 500);

console.log('2. Termina el script (pero el proceso sigue vivo)');

// El evento 'exit' se emite justo antes de morir.
process.on('exit', (codigo) => {
  // Aqui solo se puede ejecutar codigo SINCRONO: el bucle ya esta cerrado.
  console.log(`4. Saliendo con codigo ${codigo}`);
});
node src/laboratorio/ciclo-vida.js
# 1. Empieza el script
# 2. Termina el script (pero el proceso sigue vivo)
# 3. Temporizador de 500 ms disparado
# 4. Saliendo con codigo 0

Un setInterval, en cambio, nunca libera su referencia porque siempre hay una próxima ejecución programada: el proceso vive para siempre hasta que alguien llame a clearInterval o lo mates con Ctrl+C. Lo mismo ocurre con un servidor HTTP escuchando, y es justo lo que queremos: un servidor que se cerrase solo no serviría de mucho.

Hay una vía de escape, unref(), que marca una tarea como "no cuenta para mantener vivo el proceso":

// Este temporizador NO impide que el proceso termine.
const vigilante = setInterval(() => {
  console.error(`Memoria: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)} MB`);
}, 1000);

vigilante.unref();

Es el patrón habitual de las tareas de vigilancia y métricas: quieres que informen mientras la aplicación viva, pero no que la mantengan viva.

Y por último, la salida forzada:

Forma Efecto
Se acaban las tareas pendientes Salida natural y limpia. Lo normal
process.exitCode = 1 Marca el código de salida y deja terminar con normalidad. La forma correcta de indicar un error
process.exit(1) Corta inmediatamente: escrituras a medias se pierden, callbacks pendientes no se ejecutan. Usar solo en casos extremos
Excepción no capturada El proceso muere con código 1 y traza en stderr

Ya usaste process.exitCode en la lección Tu Primer Programa en Node.js; ahora sabes por qué se prefiere a process.exit().

  1. Inspeccionar el proceso: process.version y process.memoryUsage()

Node te deja mirar dentro de sí mismo en tiempo de ejecución. Dos herramientas que usarás constantemente.

9.1 Versiones

// src/laboratorio/diagnostico.js

console.log(`Node.js  : ${process.version}`);        // v22.11.0
console.log(`V8       : ${process.versions.v8}`);    // 12.4.254.21
console.log(`libuv    : ${process.versions.uv}`);    // 1.48.0
console.log(`OpenSSL  : ${process.versions.openssl}`);
console.log(`Plataforma: ${process.platform} ${process.arch}`);  // linux x64
console.log(`Nucleos  : ${require('node:os').cpus().length}`);
console.log(`PID      : ${process.pid}`);

process.version (singular, con v delante) es la versión de Node; process.versions (plural) es el objeto con todas las dependencias. Se confunden constantemente.

Un uso real: comprobar al arrancar que se cumple la versión mínima que el proyecto necesita.

// src/utiles/comprobar-version.js
// Aborta si la version de Node es anterior a la minima soportada.

const MAYOR_MINIMO = 20;

// process.version es 'v22.11.0'; quitamos la 'v' y nos quedamos con el primer numero.
const mayorActual = Number(process.version.slice(1).split('.')[0]);

if (mayorActual < MAYOR_MINIMO) {
  console.error(
    `Escena Viva necesita Node.js ${MAYOR_MINIMO} o superior. ` +
    `Tienes ${process.version}. Ejecuta "nvm use" en la raiz del proyecto.`
  );
  process.exitCode = 1;
} else {
  console.log(`Version de Node correcta: ${process.version}`);
}

Recuerda la convención del proyecto: el diagnóstico va por stderr (console.error), los datos por stdout.

9.2 Memoria

process.memoryUsage() devuelve un objeto con el consumo actual, en bytes:

// src/laboratorio/memoria.js
// Mide cuanta memoria ocupa cargar un catalogo grande en memoria.

function enMegas(bytes) {
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function informarMemoria(etiqueta) {
  const m = process.memoryUsage();
  console.log(
    `${etiqueta.padEnd(22)} rss=${enMegas(m.rss).padStart(9)}  ` +
    `heapTotal=${enMegas(m.heapTotal).padStart(9)}  ` +
    `heapUsed=${enMegas(m.heapUsed).padStart(9)}`
  );
}

informarMemoria('Al arrancar');

// Simulamos el catalogo de un ano entero: 3000 sesiones con datos.
const sesiones = [];
for (let i = 1; i <= 3000; i++) {
  sesiones.push({
    id: `ses-${String(Math.ceil(i / 3)).padStart(3, '0')}-${(i % 3) + 1}`,
    fechaHora: '2026-10-03T20:00:00',
    aforo: 420,
    vendidas: i % 420,
    precioCentimos: 2500
  });
}

informarMemoria('Con 3000 sesiones');

// Liberamos la referencia: los objetos pasan a ser basura recolectable.
sesiones.length = 0;

informarMemoria('Tras vaciar el array');
Al arrancar            rss=  42.3 MB  heapTotal=   5.6 MB  heapUsed=   4.4 MB
Con 3000 sesiones      rss=  45.1 MB  heapTotal=   8.4 MB  heapUsed=   5.7 MB
Tras vaciar el array   rss=  45.1 MB  heapTotal=   8.4 MB  heapUsed=   5.7 MB

Qué significa cada campo:

Campo Qué mide Cuándo mirarlo
rss Resident Set Size: memoria física total del proceso, incluido el propio binario de Node Para saber cuánta RAM pide tu contenedor en producción
heapTotal Montículo de V8 reservado Comparar con heapUsed para ver el margen
heapUsed Montículo de V8 realmente en uso por tus objetos La métrica clave para detectar fugas de memoria
external Memoria de objetos C++ vinculados a JavaScript (buffers, sockets) Al trabajar con Buffer (Módulo 3)
arrayBuffers Parte de external correspondiente a ArrayBuffer Datos binarios

Fíjate en la tercera línea del ejemplo: la memoria no bajó al vaciar el array. No es un error. La recolección de basura no es inmediata: V8 recuperará ese espacio cuando le convenga, no cuando tú dejes de usar los objetos. Por eso, para diagnosticar fugas, nunca mires una medida aislada: mira la tendencia de heapUsed a lo largo de minutos u horas. Una línea que sube y baja es sana; una que solo sube es una fuga.

En el Módulo 11 convertiremos esta medición manual en métricas publicadas de forma continua.

Errores Comunes y Consejos

Error 1: creer que Node es literalmente monohilo y que por eso no puede aprovechar varios núcleos. Media verdad. Tu JavaScript es monohilo, pero la E/S de ficheros, el DNS y crypto sí usan varios núcleos a través del thread pool. Lo que no se paraleliza solo es tu código.

Error 2: usar las versiones ...Sync en un servidor. fs.readFileSync en un script de una sola ejecución es perfectamente razonable. Dentro de un manejador de peticiones HTTP es un error grave: bloquea a todos los usuarios mientras el disco responde.

Error 3: subir UV_THREADPOOL_SIZE a lo bruto. Más hilos que núcleos genera competencia y cambios de contexto. Mide antes de tocarlo, y recuerda que hay que ponerlo antes de arrancar el proceso.

Error 4: confundir process.version con process.versions. La primera es una cadena ('v22.11.0'), la segunda un objeto. process.versions.node sí es equivalente al primero pero sin la v.

Error 5: llamar a process.exit() para "terminar bien". Corta el proceso en seco. Si había un fichero escribiéndose o una respuesta HTTP a medio enviar, se pierden. Usa process.exitCode y deja que el bucle termine solo.

Error 6: diagnosticar una fuga de memoria con una sola medida. heapUsed sube y baja constantemente por el propio funcionamiento del recolector. Solo la tendencia sostenida es señal de fuga.

Consejo 1: memoriza la pregunta. Ante cualquier operación, pregúntate: "¿esto es E/S o es cálculo?". Si es E/S, hay una versión asíncrona y debes usarla. Si es cálculo largo en JavaScript, no hay magia: o lo optimizas, o lo mueves a un worker, o lo sacas del proceso.

Consejo 2: ten a mano el script del latido. El setInterval que imprime cada 100 ms es la forma más barata de saber si algo está bloqueando tu proceso. En la lección siguiente lo convertiremos en una medida formal del retraso del bucle de eventos.

Consejo 3: lee el código fuente de Node. Como buena parte de la biblioteca estándar es JavaScript, lib/fs.js o lib/events.js en el repositorio de Node son perfectamente legibles y enseñan muchísimo.

Ejercicios

Ejercicio 1: encontrar el tamaño óptimo del thread pool

Amplía src/laboratorio/thread-pool.js para que, además de lo que ya hace, imprima al final un resumen: el tiempo total, el número de tareas y el tiempo medio por tarea. Después ejecuta el script con 8 tareas y con estos valores de UV_THREADPOOL_SIZE: 1, 2, 4, 8 y 16.

Rellena una tabla con los tiempos totales, explica la forma de la curva y responde: ¿por qué pasar de 8 a 16 no mejora (y probablemente empeora)?

Pista: require('node:os').cpus().length te dice cuántos núcleos tiene tu máquina. La respuesta depende de ese número.

Ejercicio 2: el informe que congela Escena Viva

Escribe src/laboratorio/informe-bloqueante.js que simule lo que ocurre en el servidor de Escena Viva durante la apertura de venta de un festival:

  1. Un setInterval cada 50 ms que imprima peticion atendida en T ms (simula a los usuarios comprando).
  2. A los 500 ms, una función síncrona calcularOcupacionGlobal() que recorra un array de 3.000 sesiones y, por cada una, haga un cálculo artificialmente costoso (por ejemplo, 200.000 iteraciones de una operación matemática) para que el total tarde en torno a un segundo.
  3. El proceso debe pararse a los 3 segundos.

Ejecútalo, cuenta cuántas peticiones se perdieron durante el bloqueo y calcula cuántos usuarios habrían quedado afectados si el servidor atendiera 200 peticiones por segundo.

Ejercicio 3: un diagnóstico de arranque para Escena Viva

Escribe src/utiles/diagnostico.js, un módulo de diagnóstico que la aplicación ejecutará al arrancar y que imprima por stderr un bloque con:

  • Versión de Node, de V8 y de libuv.
  • Plataforma, arquitectura, número de núcleos y pid.
  • Tamaño del thread pool efectivo.
  • Memoria rss y heapUsed en MB con un decimal.
  • Un aviso si la versión mayor de Node es inferior a 20, con process.exitCode = 1.
  • Un aviso si el número de núcleos es 1, indicando que cluster no aportará nada (adelanto del Módulo 10).

Debe funcionar tal cual con node src/utiles/diagnostico.js.

Soluciones

Solución 1

// src/laboratorio/thread-pool.js
const crypto = require('node:crypto');
const os = require('node:os');

const TAREAS = Number(process.argv[2]) || 5;
const ITERACIONES = 200000;
const HILOS = Number(process.env.UV_THREADPOOL_SIZE) || 4;

const inicio = Date.now();
let terminadas = 0;

console.error(`Nucleos: ${os.cpus().length} | Thread pool: ${HILOS} | Tareas: ${TAREAS}`);

for (let i = 1; i <= TAREAS; i++) {
  crypto.pbkdf2('entrada-escena-viva', 'sal', ITERACIONES, 64, 'sha512', () => {
    const transcurrido = Date.now() - inicio;
    console.log(`Tarea ${String(i).padStart(2)} terminada en ${transcurrido} ms`);

    terminadas++;
    if (terminadas === TAREAS) {
      const total = Date.now() - inicio;
      console.log('');
      console.log(`Total       : ${total} ms`);
      console.log(`Media/tarea : ${Math.round(total / TAREAS)} ms`);
      // Tandas teoricas: cuantas rondas completas del pool han hecho falta.
      console.log(`Tandas      : ${Math.ceil(TAREAS / HILOS)}`);
    }
  });
}
for n in 1 2 4 8 16; do
  echo "--- UV_THREADPOOL_SIZE=$n ---"
  UV_THREADPOOL_SIZE=$n node src/laboratorio/thread-pool.js 8 | tail -4
done

Resultados típicos en una máquina de 8 núcleos:

UV_THREADPOOL_SIZE Tandas Total aproximado Comentario
1 8 ~1400 ms Todo en serie: 8 × 175 ms
2 4 ~700 ms La mitad
4 2 ~350 ms El valor por defecto
8 1 ~190 ms Óptimo: una sola tanda, un hilo por núcleo
16 1 ~210 ms Ligeramente peor

La curva baja de forma casi proporcional hasta igualar el número de núcleos y luego se aplana o empeora. La razón es que estas tareas son de CPU pura: con 8 núcleos solo se pueden ejecutar 8 cálculos simultáneos de verdad. Con 16 hilos, el sistema operativo reparte los mismos 8 núcleos entre 16 hilos alternándolos, y ese reparto tiene un coste (cambio de contexto, invalidación de caché) sin ninguna ganancia. Más hilos solo ayudan cuando los hilos esperan en lugar de calcular, que es el caso de la E/S de ficheros lenta, no el de pbkdf2.

Solución 2

// src/laboratorio/informe-bloqueante.js
// Simula el efecto de un calculo sincrono largo sobre el trafico de Escena Viva.

const inicio = Date.now();
let atendidas = 0;

// Cada 50 ms atendemos una peticion de compra.
const trafico = setInterval(() => {
  atendidas++;
  console.log(`peticion ${String(atendidas).padStart(3)} atendida en ${Date.now() - inicio} ms`);
}, 50);

// Catalogo grande: 3000 sesiones.
const sesiones = [];
for (let i = 1; i <= 3000; i++) {
  sesiones.push({ id: `ses-${i}`, aforo: 420, vendidas: i % 420, precioCentimos: 2500 });
}

// Calculo deliberadamente costoso: por cada sesion, 200000 operaciones.
function calcularOcupacionGlobal(sesiones) {
  let acumulado = 0;
  for (const sesion of sesiones) {
    let ruido = 0;
    for (let i = 0; i < 200000; i++) {
      ruido += Math.sqrt(i);   // Trabajo artificial: representa un calculo real mal planteado.
    }
    acumulado += sesion.vendidas / sesion.aforo + ruido * 0;
  }
  return Math.round((acumulado / sesiones.length) * 100);
}

setTimeout(() => {
  const t0 = Date.now();
  console.error('>> Empieza el informe sincrono');
  const ocupacion = calcularOcupacionGlobal(sesiones);
  const duracion = Date.now() - t0;
  console.error(`>> Informe terminado: ${ocupacion}% de ocupacion, ${duracion} ms de bloqueo`);
  console.error(`>> Peticiones perdidas: ~${Math.floor(duracion / 50)}`);
}, 500);

setTimeout(() => {
  clearInterval(trafico);
  console.error(`Total de peticiones atendidas en 3 s: ${atendidas} (deberian ser ~60)`);
}, 3000);

Salida resumida:

peticion   1 atendida en 51 ms
...
peticion   9 atendida en 452 ms
>> Empieza el informe sincrono
>> Informe terminado: 50% de ocupacion, 1180 ms de bloqueo
>> Peticiones perdidas: ~23
peticion  10 atendida en 1683 ms
...
Total de peticiones atendidas en 3 s: 37 (deberian ser ~60)

Análisis: se perdieron unas 23 ranuras de 50 ms. Si el servidor real atendiera 200 peticiones por segundo, un bloqueo de 1,18 segundos habría dejado en espera a unos 236 usuarios, todos ellos con la rueda girando en el navegador aunque su compra no tuviera nada que ver con el informe. Y no es que se retrasaran un poco: se retrasaron más de un segundo, que en una apertura de venta es la diferencia entre conseguir entrada y no conseguirla.

La solución no es "optimizar el informe" (aunque ayude), sino sacarlo del hilo principal: un worker_thread (Módulo 10) o un trabajo en cola que lo calcule fuera y deje el resultado en caché.

Solución 3

// src/utiles/diagnostico.js
// Diagnostico de arranque de Escena Viva.
// Todo va por stderr: es informacion de operacion, no datos de la aplicacion.

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

const MAYOR_MINIMO = 20;

function enMegas(bytes) {
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function diagnosticar() {
  const memoria = process.memoryUsage();
  const nucleos = os.cpus().length;
  const hilosPool = Number(process.env.UV_THREADPOOL_SIZE) || 4;
  const mayorActual = Number(process.version.slice(1).split('.')[0]);

  console.error('=========================================');
  console.error('  ESCENA VIVA - DIAGNOSTICO DE ARRANQUE');
  console.error('=========================================');
  console.error(`  Node.js     : ${process.version}`);
  console.error(`  V8          : ${process.versions.v8}`);
  console.error(`  libuv       : ${process.versions.uv}`);
  console.error(`  Plataforma  : ${process.platform} ${process.arch}`);
  console.error(`  Nucleos     : ${nucleos}`);
  console.error(`  Thread pool : ${hilosPool} hilos`);
  console.error(`  PID         : ${process.pid}`);
  console.error(`  Memoria rss : ${enMegas(memoria.rss)}`);
  console.error(`  Heap usado  : ${enMegas(memoria.heapUsed)}`);
  console.error('-----------------------------------------');

  if (mayorActual < MAYOR_MINIMO) {
    console.error(`  AVISO: se requiere Node ${MAYOR_MINIMO}+. Ejecuta "nvm use".`);
    process.exitCode = 1;
  } else {
    console.error('  Version de Node: correcta.');
  }

  if (nucleos === 1) {
    console.error('  AVISO: un solo nucleo. El modulo cluster no aportara mejora.');
  }

  console.error('=========================================');
}

diagnosticar();

Detalles de la solución que conviene notar:

  • Todo por stderr. Así puedes hacer node src/catalogo.js > catalogo.txt y el diagnóstico seguirá viéndose en pantalla sin ensuciar el fichero de datos.
  • process.exitCode en lugar de process.exit(), coherente con la convención del proyecto.
  • El aviso de un solo núcleo es real: en un contenedor con --cpus=1, arrancar 4 procesos con cluster empeora el rendimiento en vez de mejorarlo.

En la lección Módulos CommonJS y require() convertiremos este fichero en un módulo reutilizable que exporte la función diagnosticar en lugar de ejecutarla al cargarse. Ahora mismo hace las dos cosas, que es justo la mala práctica que allí aprenderemos a evitar.

Conclusión

Has abierto la caja. Node.js no es "JavaScript en el servidor" a secas: es un programa en C++ que combina V8, el motor que compila y ejecuta tu JavaScript con JIT y gestiona el montículo y la recolección de basura, con libuv, la biblioteca en C que aporta el bucle de eventos, la E/S asíncrona del sistema operativo y un thread pool. Entre ambos y tu código hay dos capas más: la biblioteca estándar —en buena parte escrita en JavaScript— y los bindings de C++ que sirven de frontera.

Has corregido el tópico más repetido del ecosistema: Node no es de un solo hilo del todo. Tu JavaScript sí corre en un único hilo —y de ahí viene la tranquilidad de no tener condiciones de carrera—, pero las lecturas de fichero, las resoluciones de dns.lookup, la criptografía y la compresión viajan a un thread pool de cuatro hilos configurable con UV_THREADPOOL_SIZE, mientras que la red usa directamente epoll, kqueue o IOCP sin consumir hilos. Y no te lo has creído: lo has medido con crypto.pbkdf2, viendo cómo cuatro tareas terminan a la vez y la quinta espera su turno.

Has comprobado también el pecado capital. Un bucle síncrono de un segundo dejó el proceso sordo durante un segundo entero: sin latidos, sin temporizadores y —en un servidor real— sin atender a nadie. Esa es la razón por la que en Escena Viva un informe pesado no puede calcularse dentro de una petición, y el motivo de que el Módulo 10 exista. Por último, entiendes por qué un script termina solo: libuv cuenta las tareas pendientes y, cuando la cuenta llega a cero, el proceso emite exit y muere; un setInterval o un servidor escuchando mantienen esa cuenta viva a propósito, y unref() es la vía de escape.

Queda una pieza que hemos mencionado en cada apartado sin desarrollar: el bucle de eventos. Sabes que existe, que gira, que ejecuta callbacks y que cuenta referencias, pero no sabes en qué orden hace las cosas. Y ese orden lo explica todo: por qué setTimeout(fn, 0) no es inmediato, por qué setImmediate a veces va antes que un temporizador y a veces después, y por qué process.nextTick se cuela delante de todos. En la siguiente lección, El Bucle de Eventos (Event Loop), recorreremos sus seis fases una a una hasta que puedas predecir, línea a línea, el orden de salida de cualquier programa asíncrono.

Curso de Node.js: De Principiante a Avanzado

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

Módulo 2: Conceptos Básicos

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

Módulo 4: HTTP y Servidores Web

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

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

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

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados