libro / blog / streams-fs-nodejs

// streams

Streams y sistema de archivos en Node.js: procesa archivos gigantes sin agotar la memoria

Gustavo Arias9 min de lectura

TL;DR Para archivos pequeños, fs/promises con readFile y writeFile es lo más simple. Para archivos grandes o datos que llegan por partes, usa streams: procesan el contenido por trozos con memoria constante. Conéctalos siempre con pipeline, que gestiona los errores y cierra todo correctamente.

Leer un archivo de configuración es trivial. Procesar un log de 5 GB, exportar un millón de filas a CSV o enviar un video a un cliente es otra historia: si intentás cargarlo todo en memoria, el proceso se cae. Los streams son la respuesta de Node.js a ese problema, y una de las razones por las que es tan eficiente manejando datos. En el libro Node.js desde cero los vemos junto con el sistema de archivos; acá tenés los patrones que más vas a usar.

El módulo fs con promesas

Para archivos de tamaño razonable, la API de promesas es la más cómoda:

import { readFile, writeFile, appendFile, mkdir, readdir } from 'node:fs/promises';
import { join } from 'node:path';

const dir = join(import.meta.dirname, 'datos');
await mkdir(dir, { recursive: true });            // no falla si ya existe

await writeFile(join(dir, 'notas.txt'), 'Primera línea\n', 'utf8');
await appendFile(join(dir, 'notas.txt'), 'Segunda línea\n');

const contenido = await readFile(join(dir, 'notas.txt'), 'utf8');
console.log(contenido);

// Leer y escribir JSON
await writeFile(join(dir, 'config.json'), JSON.stringify({ puerto: 3000 }, null, 2));
const config = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8'));

console.log(await readdir(dir));   // [ 'config.json', 'notas.txt' ]
Usá path.join() para armar rutas: elige el separador correcto en Windows y en Linux. Y evitá las versiones síncronas (readFileSync) dentro de un servidor, porque bloquean el event loop.

Por qué existen los streams

readFile carga el archivo entero en memoria antes de devolverlo. Con un archivo de 5 GB, eso significa 5 GB de RAM (o un error). Un stream, en cambio, entrega los datos en trozos pequeños a medida que se leen, y la memoria usada se mantiene constante sin importar el tamaño.

TipoQué haceEjemplos
ReadableProduce datosfs.createReadStream, la petición HTTP (req)
WritableConsume datosfs.createWriteStream, la respuesta HTTP (res)
TransformRecibe datos, los transforma y los emitezlib.createGzip, un parser de CSV

Los streams también gestionan la contrapresión (backpressure): si quien escribe es más lento que quien lee, la lectura se pausa automáticamente hasta que haya espacio.

Ejemplo: comprimir un archivo con pipeline

import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';

const [origen = 'ventas.csv'] = process.argv.slice(2);

// leer → comprimir → escribir, trozo a trozo y con memoria constante
await pipeline(
  createReadStream(origen),
  createGzip(),
  createWriteStream(`${origen}.gz`),
);

console.log(`Comprimido: ${origen}.gz`);

pipeline conecta los streams, propaga los errores de cualquiera de ellos y cierra todos al terminar o al fallar. Usalo en lugar del antiguo .pipe(), que no limpia correctamente si algo sale mal.

Ejemplo: procesar un log de varios GB línea a línea

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const lineas = createInterface({
  input: createReadStream('access.log'),
  crlfDelay: Infinity,   // trata \r\n como un solo salto de línea
});

const porCodigo = {};
for await (const linea of lineas) {
  // formato típico de Nginx: ... "GET /ruta HTTP/1.1" 404 512 ...
  const codigo = linea.match(/" (\d{3}) /)?.[1];
  if (codigo) porCodigo[codigo] = (porCodigo[codigo] ?? 0) + 1;
}

console.table(porCodigo);   // { 200: 98123, 304: 1200, 404: 312, 500: 7 }

Con for await, el código parece un bucle normal, pero cada línea se lee bajo demanda: el archivo puede pesar 50 MB o 50 GB, y la memoria apenas cambia.

Streams en un servidor HTTP

En Node.js, req y res ya son streams. Eso permite servir archivos grandes sin cargarlos en memoria:

import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';

createServer(async (req, res) => {
  try {
    const archivo = 'informe-anual.pdf';
    const { size } = await stat(archivo);
    res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': size });
    await pipeline(createReadStream(archivo), res);
  } catch {
    if (!res.headersSent) res.writeHead(500);
    res.end();
  }
}).listen(3000);

En Express, res.download() y res.sendFile() hacen esto mismo por vos (ver tu primer servidor con Express).

Errores comunes

Referencia: Stream y File system en la documentación de Node.js.

Domina la entrada y salida en Node.js

El libro Node.js desde cero explica el sistema de archivos y los streams con ejemplos prácticos, antes de pasar a servidores con Express.

Comprar en Amazon