Volver a las notas

Streams en Node.js: cuando la contrapresión muerde la memoria

17 de octubre de 2026·
nodejsstreamsmemoriabackpressure

Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía una anécdota inventada y código que medía lo que no era.

Si escribes en un stream más rápido de lo que el otro extremo puede leer, Node.js no tira nada: lo guarda en memoria. Con una respuesta HTTP y un cliente lento, eso significa que una sola descarga puede hacer crecer tu proceso en gigas. El arreglo es una línea, pero antes merece la pena ver el problema en directo, porque la métrica que mira casi todo el mundo no lo enseña.

La señal que estás ignorando

writable.write(chunk) devuelve un booleano. false significa que el búfer interno del stream ha pasado su highWaterMark y que deberías dejar de escribir hasta que emita 'drain'. Es un aviso: si sigues llamando a write(), Node sigue aceptando trozos y los guarda en memoria. Eso es la contrapresión (backpressure), y cuando el productor la ignora, la memoria se acumula.

La reproducción, medida

Un servidor que produce 2.000 trozos de 1 MB (2 GB en total), con dos rutas: una que ignora write() y otra que usa pipeline():

// server.mjs
import http from 'node:http';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const MB = 1024 * 1024;
const mem = () => {
  const { heapUsed, rss, arrayBuffers } = process.memoryUsage();
  return `heapUsed=${Math.round(heapUsed / MB)}MB rss=${Math.round(rss / MB)}MB buffers=${Math.round(arrayBuffers / MB)}MB`;
};

function producer() {
  let i = 0;
  return new Readable({
    read() {
      this.push(i++ < 2000 ? Buffer.alloc(MB, i % 256) : null);
    },
  });
}

http.createServer(async (req, res) => {
  const source = producer();
  if (req.url === '/leak') {
    // Mal: se ignora lo que devuelve res.write()
    source.on('data', (chunk) => res.write(chunk));
    source.on('end', () => res.end());
    res.on('close', () => source.destroy());
  } else {
    // Bien: pipeline pausa el productor cuando res.write() devuelve false
    try {
      await pipeline(source, res);
    } catch (err) {
      if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err);
    }
  }
}).listen(3456);

setInterval(() => console.log(mem()), 1000).unref();

Y un cliente lento que lee un trozo cada 50 ms y cuelga a los 6 segundos:

// slow-client.mjs
import http from 'node:http';

const req = http.get(`http://localhost:3456${process.argv[2]}`, (res) => {
  res.pause();
  const timer = setInterval(() => res.read(), 50);
  setTimeout(() => { clearInterval(timer); req.destroy(); }, 6000);
});
req.on('error', () => {});

Ejecutando node slow-client.mjs /leak y luego /fixed contra el servidor, con Node 22, esto es lo que imprimió el servidor:

/leak   heapUsed=6MB  rss=2052MB  buffers=2000MB
/fixed               rss=57MB    buffers=4MB

Con el cliente lento, la ruta /leak acaba reteniendo los 2 GB que generó el productor, porque nada le dijo que esperase. Con pipeline(), la memoria se queda en unos 55 MB durante toda la descarga.

La trampa: heapUsed no se mueve

Fíjate en la primera línea: heapUsed se queda en 6 MB mientras el proceso ocupa 2 GB. La memoria de los Buffer vive fuera del heap de V8. Aparece en arrayBuffers y external de process.memoryUsage(), y en rss, pero no en heapUsed. Si tu monitorización solo grafica el heap, este tipo de fuga no aparece hasta que el sistema mata el proceso. Cuando trabajes con streams y datos binarios, mira rss y arrayBuffers.

El arreglo: pipeline()

pipeline() (en su versión con promesas, de node:stream/promises) conecta los streams, respeta la contrapresión por sí solo (pausa el origen cuando el destino lo pide y lo reanuda con 'drain'), propaga los errores y destruye todos los streams de la cadena si uno falla o el cliente se desconecta. Esto último también importa: con .pipe(), un error no cierra los demás streams, y tienes que poner los listeners a mano.

Si por algún motivo escribes a mano, el patrón es:

source.on('data', (chunk) => {
  if (!res.write(chunk)) {
    source.pause();
    res.once('drain', () => source.resume());
  }
});

Pero pipeline() ya hace esto, y además se encarga de los errores y la limpieza.

Detalles a tener en cuenta

  • highWaterMark es un umbral, no un límite. Decide cuándo write() empieza a devolver false. Desde Node.js 22, el valor por defecto para streams de bytes es de 64 KiB (antes era de 16 KiB). En modo objeto son 16 objetos, pesen lo que pesen.
  • Tus Transform también cuentan. Si un Transform hace this.push() en bucle sin mirar lo que devuelve, también puede acumular memoria. Dentro de un pipeline(), la cadena solo respeta la contrapresión si cada eslabón lo hace.
  • Iteradores asíncronos. for await (const chunk of readable) también respeta la contrapresión en la lectura: no pide más hasta que terminas de procesar el trozo actual. El problema siempre está en el lado que escribe.

Qué vigilar

  • rss y arrayBuffers en tus métricas si sirves archivos, exportaciones o proxies.
  • Cualquier .on('data', …) que escribe en otro stream sin mirar lo que devuelve write().
  • Las descargas que duran minutos: son las que convierten un cliente lento en un problema de memoria.

Fuentes