Ficheiros grandes no Node sem encher a memória
Este código funciona em desenvolvimento e cai em produção:
import { readFile } from 'node:fs/promises';
const conteudo = await readFile('registos.csv', 'utf8');
const linhas = conteudo.split('\n');Com um ficheiro de teste de 2 MB, é instantâneo. Com o ficheiro real de 3 GB, o processo tenta carregar tudo para memória de uma vez — e morre, ou leva o servidor com ele.
Os streams existem para isto: processar aos bocados, com a memória a manter-se constante independentemente do tamanho.
1. Ler linha a linha
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const linhas = createInterface({
input: createReadStream('registos.csv', 'utf8'),
crlfDelay: Infinity, // trata \r\n como uma quebra só
});
let total = 0;
for await (const linha of linhas) {
total += Number(linha.split(',')[3] ?? 0);
}
console.log(total);O ficheiro pode ter 3 GB: em memória está uma linha de cada vez.
2. Ligar as pontas com pipeline
Quando há transformação pelo meio — comprimir, converter, filtrar — a forma correta é o pipeline, e não encadear .pipe() à mão:
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('registos.csv'),
createGzip(),
createWriteStream('registos.csv.gz')
);
console.log('comprimido');A diferença não é de estilo: o pipeline propaga os erros e fecha tudo quando um dos lados falha. Com .pipe(), um erro a meio deixa ficheiros abertos e o processo pendurado.
3. A parte que se esquece: a contrapressão
Se o destino for mais lento do que a origem — escrever num disco lento, enviar por rede — os streams travam a leitura sozinhos. É isso que impede a memória de crescer enquanto o consumidor não acompanha.
Escrever o mesmo ciclo à mão com readFile e writeFile não tem nada disto: lê tudo à velocidade máxima e acumula o que o outro lado ainda não conseguiu escrever.