Por que streams

TL;DR

Streams são a abstração do Node para processar dados em chunks sem carregar tudo em memória. Use quando o payload é grande (>100 MB), o throughput é sustentado, ou backpressure precisa ser respeitado. Alternativas mais simples (buffer everything, paginação) ganham em casos pequenos; streams ganham em casos grandes ou de longa duração.


O que é

Um stream é uma sequência de chunks que pode ser produzida ou consumida incrementalmente — sem materializar o conjunto completo de dados de uma só vez.

A metáfora útil: ler um livro página a página em vez de memorizá-lo inteiro antes de começar. O conteúdo existe em ordem, mas apenas uma parte precisa estar “em mãos” a qualquer momento.

Node.js expõe quatro tipos de stream nativos (detalhados na nota 02):

TipoPapelExemplo
ReadableFonte de dados — produz chunksfs.createReadStream, req HTTP
WritableDestino de dados — consome chunksfs.createWriteStream, res HTTP
DuplexLê e escreve de forma independentenet.Socket, conexão TCP
TransformLê, transforma, e escrevezlib.createGzip, crypto.createCipheriv

A diferença fundamental em relação a um array completo:

DimensãoArray / Buffer completoStream
Uso de memóriaO(N) — cresce com os dadosO(chunkSize) — constante
Primeiro outputApós carregar tudoApós receber o primeiro chunk
ComposiçãoEncadeia operações sobre coleçõesEncadeia transformações sobre o fluxo
BackpressureInexistenteNativo — produtor pode ser pausado

Por que importa

O problema concreto surge em três cenários frequentes em servidores de produção.

Upload ou download de arquivos grandes. Um endpoint que recebe um arquivo de 5 GB e faz const data = await readFile(path) antes de processar precisa de pelo menos 5 GB de heap disponível — por requisição. Com 3 requisições simultâneas, são 15 GB. A heap do processo Node tem limite configurável, mas nenhum servidor sobrevive a esse padrão sob carga.

Pipelines de transformação de dados. Um job que processa um CSV de 2 milhões de linhas: se a lógica carrega todas as linhas antes de começar a processar, a latência do primeiro output é proporcional ao tamanho total do arquivo. Com streaming, o primeiro registro pode ser escrito na saída antes que 1% do arquivo seja lido.

Streaming de respostas longas. Respostas SSE (Server-Sent Events) e respostas de LLMs chegam em partes ao longo de segundos. Se o servidor faz buffer da resposta completa antes de repassar ao cliente, o usuário espera sem ver progresso — a UX quebra. Com streaming, cada chunk é encaminhado assim que chega.

O event loop é o elo de ligação aqui. Como visto em 10 - Bloqueio do event loop - sintomas e causas, carregar um payload gigante com JSON.parse ou readFileSync é uma das causas canônicas de bloqueio da thread JavaScript. Streams evitam esse bloqueio ao não forçar a materialização completa dos dados na thread principal.

O ponto sutil: o problema não é apenas memória — é a combinação de memória e thread. Um readFile de 500 MB bloqueia porque:

  1. Reserva 500 MB de heap de uma vez
  2. A desserialização subsequente (JSON.parse de payload grande) bloqueia a thread JS por centenas de milissegundos

Com stream, cada chunk chega de forma assíncrona, é processado, e é descartado. A thread JS nunca segura mais do que um chunk de cada vez.


Como funciona

Buffer everything vs. stream — comparação direta

Abordagem buffer everything:

import { readFile, writeFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';
 
// ❌ Carrega o arquivo inteiro antes de processar qualquer linha
const raw = await readFile('registros.csv');          // O(N) memória
const rows = parse(raw, { columns: true });           // O(N) memória adicional
const result = rows.map(transformarRegistro);         // O(N) memória adicional
await writeFile('saida.json', JSON.stringify(result));// O(N) memória adicional
 
// Pico de memória: aproximadamente 4× o tamanho do CSV

Abordagem stream:

import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { parse } from 'csv-parse';
import { stringify } from 'ndjson';
 
// ✅ Processa chunk a chunk — memória constante independente do tamanho
await pipeline(
  createReadStream('registros.csv'),
  parse({ columns: true }),
  new Transform({
    objectMode: true,
    transform(row, _enc, cb) { cb(null, transformarRegistro(row)); },
  }),
  stringify(),
  createWriteStream('saida.ndjson'),
);
 
// Pico de memória: O(chunkSize) — independente do tamanho total do arquivo

O crescimento de memória com buffer everything é linear no tamanho dos dados. Com stream, o buffer interno tem tamanho fixo (controlado por highWaterMark). A função pipeline da node:stream/promises também cuida de propagação de erros e cleanup automático — mais sobre isso na nota 07.

Diagrama mental do fluxo

Buffer everything:
  Fonte ──────────────────────────────────► Memória (N bytes) ──► Consumidor
         espera carregar tudo antes de prosseguir

Stream:
  Fonte ──► [chunk 1] ──► Transform ──► [chunk 1'] ──► Consumidor
         ──► [chunk 2] ──► Transform ──► [chunk 2'] ──► Consumidor
         ──► [chunk 3] ──► Transform ──► [chunk 3'] ──► Consumidor
         Pipeline fluindo continuamente — memória = O(buffer interno)

Backpressure — o mecanismo que controla o fluxo

Backpressure é o mecanismo pelo qual um consumidor lento sinaliza ao produtor para reduzir a velocidade. Sem backpressure, um produtor rápido (leitura de disco em NVMe) conectado a um consumidor lento (escrita em rede com latência alta) encheria o buffer interno até o heap explodir.

// Sem backpressure — produtor ignora a pressão do consumidor
const readable = createReadStream('grande.bin');
const writable = createWriteStream('/dev/null');
 
readable.on('data', (chunk) => {
  // ❌ Se writable.write() retornar false (buffer cheio), ignoramos
  writable.write(chunk);
});
 
// Com backpressure respeitado — via pipeline (forma correta)
await pipeline(
  createReadStream('grande.bin'),
  createWriteStream('/dev/null'),
);
// pipeline pausa o readable automaticamente quando o writable está cheio

A conexão com o galho 2 (Paralelismo) surge aqui: quando dados precisam ser transferidos entre Worker Threads via postMessage, a alternativa é usar transferList com ArrayBuffer para zero-copy. Quando os dados fluem entre processos ou entre rede e disco, streams são o mecanismo correto — cada um evita cópias desnecessárias em seu contexto. Ver 04 - Comunicação entre workers - postMessage e MessageChannel.

highWaterMark — controlando o buffer interno

Cada stream tem um buffer interno cujo tamanho máximo é controlado por highWaterMark. Para streams em modo bytes (padrão), o valor é em bytes (padrão: 16 KB). Para streams em modo objeto (objectMode: true), o valor é em número de objetos (padrão: 16).

import { createReadStream } from 'node:fs';
 
// highWaterMark de 64 KB — chunks maiores, menos chamadas de sistema
const readable = createReadStream('grande.csv', { highWaterMark: 64 * 1024 });
 
// Para streams de objetos (ex.: parsing de CSV linha a linha)
const { Transform } = require('node:stream');
const parser = new Transform({
  objectMode: true,
  highWaterMark: 100, // máximo de 100 objetos no buffer interno
  transform(chunk, _enc, cb) { /* ... */ cb(null, parsed); },
});

highWaterMark não é um limite rígido — é o threshold após o qual write() retorna false (sinalizando ao produtor para pausar). Aumentar highWaterMark melhora o throughput mas aumenta o uso de memória; diminuir reduz a memória mas pode aumentar a latência por pausas mais frequentes. Para a maioria dos casos, o valor padrão (16 KB) é adequado.


Na prática

Quando usar streams

  • Arquivos ou payloads maiores que ~100 MB
  • Throughput sustentado em servidor sob carga (uploads, downloads, transformações contínuas)
  • Respostas cujo primeiro chunk precisa ser entregue ao cliente antes do final (SSE, LLM, progress)
  • Composição de múltiplas transformações sequenciais (parse → filter → transform → serialize)
  • Qualquer situação onde o tamanho total dos dados é desconhecido em tempo de execução

Quando NÃO usar streams

SituaçãoAlternativa adequadaMotivo
Payload < 10 MBreadFile + processamento síncronoOverhead de stream > benefício; mais simples
Operação que precisa ver todos os dados de uma vezBuffer completoSort global, dedup global, join de datasets
Latência ponta-a-ponta importa mais que throughputBuffer + paginaçãoStream tem latência de primeira resposta similar ao buffer
Uma única transformação simples sobre dados pequenosBuffer + array methods.map, .filter, .reduce são mais legíveis e suficientes
Dados JSON estruturados sem volume extremoJSON.parse diretaO custo de parsing é desprezível abaixo de ~5 MB

A decisão não é “streams são sempre melhores”. É “streams trocam complexidade por eficiência de memória e throughput”. Para datasets pequenos, a complexidade não se paga.

Exemplo completo: upload de arquivo grande

import { createServer } from 'node:http';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib';
 
// ✅ Recebe upload, descomprime, salva — sem carregar tudo em memória
createServer(async (req, res) => {
  if (req.method !== 'POST') return res.end();
 
  try {
    await pipeline(
      req,              // Readable: stream do body HTTP
      createGunzip(),   // Transform: descomprime gzip on-the-fly
      createWriteStream('/tmp/upload.dat'), // Writable: salva no disco
    );
    res.writeHead(200);
    res.end(JSON.stringify({ ok: true }));
  } catch (err) {
    // pipeline propaga erros e faz cleanup de todos os estágios
    res.writeHead(500);
    res.end(JSON.stringify({ error: err.message }));
  }
}).listen(3000);

Neste padrão, um upload de 5 GB usa apenas ~16 KB de heap por chunk — independente do tamanho total. O mesmo servidor pode lidar com múltiplos uploads simultâneos sem explodir a memória.


Armadilhas

1. Usar stream em payload pequeno — overhead sem benefício.

Stream adiciona indireção: criação de objetos, eventos, tratamento de backpressure, propagação de erros. Para arquivos de 50 KB ou payloads de API típicos, esse overhead é mensurável e não compensa. A heurística: abaixo de 10 MB e sem requisito de latência de primeiro chunk, buffer é mais simples e igualmente eficiente.

2. Confundir “streaming HTTP” com “Node Streams”.

Quando uma resposta HTTP chega em chunks via Transfer-Encoding chunked ou SSE, isso é streaming no protocolo. Node Streams são a abstração do runtime para processar esses (e outros) dados. Os dois se relacionam — req e res são Node Streams — mas não são a mesma coisa. É possível consumir uma resposta HTTP em streaming sem usar a API stream explicitamente (usando fetch com response.body), e é possível usar Node Streams sem envolver HTTP (arquivos, stdin/stdout).

3. Achar que stream resolve memória, mas ignorar backpressure.

O argumento de “memória constante” assume que o produtor e o consumidor operam em velocidades compatíveis ou que backpressure está sendo respeitado. Se um Readable drena disco em NVMe mas o Writable é uma conexão de rede lenta, e backpressure é ignorado, o buffer interno do Writable cresce indefinidamente — a memória explode mesmo com stream. A conclusão errada seria “streams não ajudam com memória”. A conclusão correta: streams ajudam, mas apenas quando backpressure está implementado. pipeline implementa backpressure automaticamente; pipe faz o mesmo mas tem semântica de erro fraca; conectar manualmente com eventos data exige cuidado explícito.

4. Usar stream.pipe() em código novo.

pipe não propaga erros corretamente entre estágios e não destrói streams upstream quando um downstream falha — o que leva a leaks de file descriptors. pipeline (da node:stream/promises) substitui pipe em todos os casos de produção. Mais detalhes na nota 07.


Em entrevista

Frase pronta (EN)

“Node Streams are the canonical way to process data in chunks without loading everything into memory. The motivation is concrete: large file processing, sustained throughput on a server, and respecting backpressure between fast producers and slow consumers. They’re not always the right answer — for small payloads, the overhead exceeds the benefit, and for operations that need a global view of the data, you need the full buffer anyway. The signal that you should reach for streams is when memory or latency under load is the bottleneck.”

Vocabulário técnico

PT-BREN
chunkchunk
throughputthroughput
backpressurebackpressure
produtor / consumidorproducer / consumer
latêncialatency
buffer internointernal buffer
alto nível d’águahigh-water mark
fluxo de dadosdata flow
pipeline de transformaçãotransformation pipeline

Perguntas frequentes em entrevista

“Qual a diferença entre stream e buffer em Node?” Buffer carrega todos os dados em memória antes de qualquer processamento — uso de memória O(N). Stream entrega dados em chunks incrementais — uso de memória O(chunkSize). A diferença é relevante para payloads grandes; para dados pequenos, ambos têm desempenho equivalente.

“Quando streams não são a resposta certa?” Quando o payload é pequeno (overhead de stream não se paga), quando a operação exige visão global dos dados (sort, dedup, join), ou quando a latência de entrega do primeiro chunk não é um requisito — nesse caso, buffer com paginação pode ter latência total menor com menor complexidade.

“O que é backpressure e por que importa?” Backpressure é o mecanismo pelo qual um consumidor lento sinaliza ao produtor para pausar. Sem ele, um produtor rápido enche o buffer interno do consumidor até o heap estourar. pipeline gerencia backpressure automaticamente; pipe faz o mesmo; conectar streams manualmente via eventos data exige implementar backpressure explicitamente via readable.pause() / readable.resume().


Veja também