Inicio / Artículos / Explicación de Node.js Streams: Cómo solucionar los fallos por falta de memoria al trabajar con archivos

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.

1993 palabras

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 .gzip mientras 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, pipeline detiene 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 pipeline o 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

  • Node.js Streams más allá de lo básico: Memoria, presión retroalimentaria y fallos reales — Aprenda cómo interactúan los streams de Node.js con Web Streams, los ahorros de memoria medidos mediante pruebas reales, y los errores en producción que solo aparecen bajo carga.
  • Comprendiendo Node.js Streams: El problema que realmente resuelven — Aprenda por qué existen los streams de Node.js, cómo funciona internamente el sistema de tuberías y qué significa realmente la presión retroalimentaria para manejar grandes volúmenes de datos de manera eficiente.
  • Sesiones vs. JWT: Elegir el modelo de autenticación correcto en Node.js — Aprenda cómo difieren realmente las sesiones y los JWT en la autenticación de Node.js, cuáles son sus limitaciones y cómo elegir entre ellos sin arrepentirse después.
  • Node.js 26: API Temporal, Map Upserts y Undici 8 explicados — Explica los principales cambios en la parte posterior del sistema de Node.js 26, incluida la API Temporal estable, los métodos nativos de Map upsert, las mejoras de rendimiento en Undici 8 y los cambios que pueden causar problemas, para poder auditarlos antes de actualizar.