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
- La pila de Node.js capa por capa
- V8: el motor que ejecuta tu JavaScript
- libuv: el motor que hace posible la asincronía
- El matiz clave: Node no es de un solo hilo del todo
- Qué va al thread pool y qué no
- Demostración medible: los cuatro hilos y el quinto que espera
- El pecado capital: bloquear el hilo principal
- El ciclo de vida de un proceso Node
- Inspeccionar el proceso:
process.versionyprocess.memoryUsage()
- 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.jsincrustados en el binario. Puedes leerlos: están en el repositorio de Node en la carpetalib/. 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.
- 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:
- 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.
- Observación. Mientras se ejecuta, V8 registra qué funciones se llaman mucho y con qué tipos de datos.
- Optimización. Un compilador optimizador (TurboFan) toma las funciones "calientes" y genera código máquina especializado para los tipos que ha observado.
- 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 conJavaScript 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.
- 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:
- 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).
- La E/S asíncrona del sistema operativo. Para red, libuv usa el mecanismo nativo de notificación de cada plataforma:
epollen Linux,kqueueen 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. - 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.
- 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 unoEn 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.
- 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.
- 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:
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 5Thread 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=128no 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 muchocrypto, y medir siempre antes y después. La variable debe estar puesta antes de arrancar el proceso: cambiarla desde dentro conprocess.env.UV_THREADPOOL_SIZE = 8no tiene efecto, porque el pool ya se ha creado.
- 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.
- 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 0Un 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().
- Inspeccionar el proceso:
process.version y process.memoryUsage()
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:
- Un
setIntervalcada 50 ms que imprimapeticion atendida en T ms(simula a los usuarios comprando). - 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. - 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
rssyheapUseden 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
clusterno 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
doneResultados 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 hacernode src/catalogo.js > catalogo.txty el diagnóstico seguirá viéndose en pantalla sin ensuciar el fichero de datos. process.exitCodeen lugar deprocess.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 conclusterempeora 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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
