Explicación de Node.js Streams: Cómo solucionar los fallos por falta de memoria al trabajar con archivos
Aprenda por qué cargar archivos completos en la memoria hace que fallen los servidores Node.js y cómo las transmisiones legibles, escritables, duales y de transformación lo solucionan mediante contrapresión.
Imagínese un servidor de producción que deja de funcionar en medio de una tarde normal y tranquila.
No hubo aumento repentino de tráfico ni afluencia masiva de usuarios simultáneos. Solo una persona utilizando la aplicación, quien hizo clic en un botón para exportar un informe grande.
En cuestión de segundos, el proceso dejó de responder por completo, y la consola mostró un mensaje familiar: “JavaScript heap out of memory.”
Si ya ha enfrentado ese error antes, sabe lo perturbador que es.
La reacción natural es la confusión. ¿Cómo puede un solo archivo, solicitado por un usuario, hacer que deje de funcionar toda una aplicación en ejecución?
Ese tipo de incidente es un excelente maestro. Señala directamente a un concepto fundamental de Node.js que todo desarrollador backend debe llegar a comprender: flujos.
El gran error que cometen la mayoría de los principiantes
Cuando los desarrolladores son nuevos en Node.js, suelen recurrir a las herramientas más simples disponibles.
Para leer un archivo del disco, la opción habitual es fs.readFile(). Es sencilla de usar: se pasa una ruta, se utiliza una función de callback o await, y el contenido completo del archivo se devuelve.
Una versión típica de esto sería:
import fs from 'node:fs/promises';
async function sendFile(filePath) {
// Reading the entire file at once
const bigData = await fs.readFile(filePath);
return bigData;
}
Este enfoque funciona bien siempre y cuando los archivos sean pequeños. Un archivo de texto de 50 kilobytes se carga al instante. Una imagen de perfil pequeña tampoco representa ningún problema.
Dado que todo funciona correctamente durante las pruebas locales, es tentador asumir que el código está listo para producción tal como está.
Pero luego llega la realidad.
Por qué leer todo de una vez falla
Considere cómo se utiliza realmente la RAM de su máquina. Cuando se ejecuta fs.readFile(), Node.js carga todo el archivo en la memoria, byte a byte, antes de devolverlo.
Supongamos que su servidor solo tiene 1 gigabyte de RAM asignado a la aplicación.
Ahora supongamos que un usuario intenta subir un video o solicita un archivo de registro en bruto de 900 megabytes.
Llamar a fs.readFile() sobre ese archivo de 900 megabytes desencadena una reacción en cadena:
- Node.js solicita de inmediato 900 megabytes de memoria al sistema operativo.
- El recolector de basura trabaja horas extras a medida que la memoria disponible disminuye.
- Si un segundo usuario solicita el mismo archivo al mismo tiempo, la demanda de memoria aumenta a 1800 megabytes.
- El servidor agota su presupuesto de memoria y se cae por completo.
La falla no es causada por un archivo dañado. Ocurre porque todo el contenido se ingiere de golpe en lugar de ser consumido gradualmente.
¿Qué son las corrientes en palabras sencillas?
Aléjate un momento del código y piensa en una analogía del mundo real.
Imagina que necesitas trasladar agua de un lago grande al jardín de tu casa.
No intentarías sacar todo el agua del lago con una sola cubeta enorme y llevarla hasta allí: ese peso es simplemente demasiado grande para que cualquiera lo levante.
En lugar de eso, conectarías una manguera de jardín.
El agua fluye a través de esa manguera en un chorro delgado y continuo: entra por un extremo, recorre el tubo y sale por el otro extremo sobre la tierra.
Con solo una manguera estrecha, puedes transportar millones de litros con el tiempo, sin tener que levantar nunca toda la cantidad de una sola vez.
Un flujo en Node.js funciona exactamente como esa manguera.
En lugar de cargar todo un archivo en la memoria de una sola vez, un flujo lo lee en fragmentos pequeños y manejables conocidos como chunks.
Por defecto, un chunk suele tener alrededor de 64 kilobytes.
Node.js toma un chunk, lo procesa, lo envía a donde sea necesario y luego lo libera de la memoria antes de pasar al siguiente chunk.
Por esta razón, un servidor puede transmitir un archivo de 10 gigabytes consumiendo solo entre 20 y 30 megabytes de RAM.
Los cuatro tipos de flujos en Node.js
Node.js ofrece cuatro componentes fundamentales para trabajar con datos en flujo. No es necesario dominar cada detalle de inmediato, pero vale la pena saber cómo se denominan:
1. Flujos legibles
Un flujo legible es aquel del cual se obtienen los datos.
- Los ejemplos incluyen leer un archivo desde el disco, recibir el cuerpo de una solicitud HTTP entrante o leer filas a partir de una consulta a la base de datos.
2. Flujos escritibles
Un flujo escribible es aquel en el que se insertan datos.
- Los ejemplos incluyen escribir contenido en un archivo nuevo, enviar una respuesta de vuelta al navegador o escribir bytes a través de un socket de red.
3. Flujos dúplex
Un flujo dúplex permite realizar ambas acciones al mismo tiempo: se puede leer de él y escribir en él simultáneamente.
- Ejemplo: una conexión de red, como un socket TCP, donde se envían datos y se reciben datos de vuelta a través de la misma conexión.
4. Flujos de transformación
Un flujo de transformación es un flujo dúplex especializado. Su función es modificar los datos a medida que pasan por él, en lugar de simplemente transportarlos sin cambios.
- Ejemplo: comprimir un archivo en formato
.gzipmientras fluye, o cifrar el texto en su camino hacia el disco.
Viendo la diferencia: ejemplos de código
Comparemos estos enfoques con un escenario concreto. Imagine que está creando un servidor HTTP básico que permite a los visitantes descargar un archivo grande.
El método incorrecto (alto consumo de memoria)
JavaScript
import http from 'node:http';
import fs from 'node:fs/promises';
const server = http.createServer(async (req, res) => {
try {
// We load the whole file into RAM first
const fileData = await fs.readFile('./massive-dataset.csv');
res.writeHead(200, { 'Content-Type': 'text/csv' });
res.end(fileData);
} catch (error) {
res.writeHead(500);
res.end('Something broke');
}
});server.listen(3000);
Si massive-dataset.csv tiene 2 gigabytes de tamaño, este código intentará mantener los 2 gigabytes completos en memoria antes incluso de enviar un solo byte al cliente. En la mayoría de las configuraciones de alojamiento en la nube, esto hará que el proceso se cierre inmediatamente.
El método mejor (bajo consumo de memoria)
Ahora construyamos la misma función de descarga utilizando flujos en su lugar:
JavaScript
import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer((req, res) => {
// We create a readable stream
const readStream = fs.createReadStream('./massive-dataset.csv'); res.writeHead(200, { 'Content-Type': 'text/csv' }); // We connect our read stream directly to the response
readStream.pipe(res); readStream.on('error', (err) => {
res.writeHead(500);
res.end('File not found or error reading');
});
});server.listen(3000);
¿Nota la llamada a .pipe()?
Esa única llamada a un método logra algo muy poderoso: conecta directamente nuestro flujo de lectura de archivos con la respuesta HTTP saliente (res).
En el momento en que el disco entrega el primer fragmento pequeño (digamos, 64 KB), Node.js lo envía de inmediato al cliente. No hay necesidad de esperar a que se haya leído todo el archivo. El uso de memoria se mantiene bajo y constante durante toda la duración de la descarga.
Comprendiendo la contrapresión (el problema del atasco de tráfico)
Existe un concepto clave en la transmisión por flujo que todo desarrollador debería entender: la contrapresión.
Vuelva un momento a la analogía de la manguera del jardín.
Imagínese empujando agua hacia una tubería a 100 litros por segundo, mientras que la válvula de salida solo permite que salgan 10 litros por segundo.
La presión sigue aumentando dentro de la tubería, y si esta no es lo suficientemente resistente, estalla.
El mismo tipo de problema aparece constantemente en el software. Una SSD puede suministrar datos a cientos de megabytes por segundo. Mientras tanto, la persona que descarga tu archivo podría estar conectada a una red móvil lenta.
Así que, si Node.js sigue extrayendo datos del disco más rápido de lo que el cliente puede recibirlos, ¿dónde acaba ese exceso de datos?
Se acumulan en la RAM de tu servidor, esperando ser enviados.
Si no se controla, esto anula todo el propósito de usar flujos, ya que el consumo de memoria vuelve a aumentar rápidamente.
Cómo lo resuelve el Node.js moderno
Afortunadamente, las versiones actuales de Node.js incluyen una solución integrada para exactamente este problema: la función pipeline, disponible en el módulo stream/promises.
En lugar de depender del enfoque antiguo .pipe(), el código moderno debería preferir pipeline:
JavaScript
import http from 'node:http';
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
const server = http.createServer(async (req, res) => {
const readStream = fs.createReadStream('./massive-dataset.csv'); try {
// pipeline handles backpressure and cleans up automatically
await pipeline(readStream, res);
} catch (error) {
if (!res.headersSent) {
res.writeHead(500);
res.end('Transfer failed');
}
}
});server.listen(3000);
¿Qué hace que pipeline sea una mejor opción que .pipe()?
- Reacciona ante velocidades incompatibles: cuando el cliente es lento para recibir datos, pausa automáticamente la secuencia de lectura hasta que pueda aceptar más.
- Gestiona los errores de manera adecuada: si alguien cierra su navegador durante la descarga,
pipelinedetiene la secuencia de lectura y libera correctamente el handle del archivo, evitando fugas de memoria.
Situaciones reales en las que los flujos te ayudan
Los flujos no están reservados únicamente para enviar archivos de video enormes o descargas masivas. Aparecen silenciosamente en todo tipo de escenarios cotidianos de producción:
- Procesamiento de registros: Para escanear un registro de servidor masivo en busca de errores no es necesario cargar todo el archivo en la memoria. En su lugar, se puede procesarlo línea por línea.
- Transformaciones de imágenes y videos: Cuando alguien sube una foto de alta resolución, se puede enviar directamente la carga recibida a una herramienta de redimensionamiento de imágenes, omitiendo el paso de guardar primero el archivo en bruto en el disco.
- Exportación de bases de datos: Al exportar millones de filas a un formato CSV, se pueden obtener filas en pequeños lotes desde el cursor de la base de datos y enviarlas directamente al cliente a medida que llegan.
- Cifrado de datos: Cifrar información sensible mientras se escribe en el almacenamiento en la nube.
Errores comunes que hay que evitar
Incluso los desarrolladores que comprenden la teoría detrás de las transmisiones por flujo pueden tropezar con algunos problemas prácticos:
- Omitir los manejadores de errores: Las API de flujo antiguas no propagan automáticamente los errores. Si un paso en su pipeline lanza una excepción y no hay nada que la detecte, todo el proceso puede fallar. Utilice
pipelineo escuche explícitamente el evento'error'. - Convertir flujos de nuevo en buffers: Es tentador recopilar cada evento
'data'en un array y luego concatenar todo en una sola cadena o buffer grande. Hacer esto anula los beneficios de memoria que intentaba obtener en un principio. - Dejar recursos abiertos: Si una operación falla a mitad de camino, asegúrese de que todos los descriptores de archivo abiertos se cierren correctamente en lugar de permanecer abiertos.
Consideraciones finales
Cuando los desarrolladores son nuevos en la programación, tienden a ver los datos como algo fijo y completo, esperando allí ser utilizado, ya sea un archivo entero, una tabla de base de datos completa o una respuesta terminada.
Trabajar profesionalmente en sistemas backend requiere abandonar ese modelo mental.
Los datos no siempre son un objeto sólido e inmóvil. Con frecuencia, se comportan como un río en flujo.
No es necesario tomar todo el río para interactuar con él; basta con dejar que fluya a tu alrededor, poco a poco.
Una vez que los streams formen parte de su conjunto de herramientas, los archivos grandes dejarán de ser algo que temer. Su infraestructura podrá funcionar en servidores más eficientes y económicos. Sus aplicaciones responderán mejor a quienes las utilizan. Y quizás lo más importante, podrá estar tranquilo sabiendo que una subida inesperadamente grande de 2 GB no dejará su servidor sin funcionar en medio de la noche.
Leer más
- Explicación de la concurrencia en Node.js: libuv, el bucle de eventos y el pool de hilos — Aprenda cómo Node.js utiliza las primitivas del sistema operativo de libuv y su pool de hilos de trabajo para manejar E/S asíncrona, además de los problemas comunes en el pool de hilos y consejos para su ajuste.