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
highWaterMarkes un umbral, no un límite. Decide cuándowrite()empieza a devolverfalse. 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
Transformhacethis.push()en bucle sin mirar lo que devuelve, también puede acumular memoria. Dentro de unpipeline(), 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
rssyarrayBuffersen tus métricas si sirves archivos, exportaciones o proxies.- Cualquier
.on('data', …)que escribe en otro stream sin mirar lo que devuelvewrite(). - Las descargas que duran minutos: son las que convierten un cliente lento en un problema de memoria.