Destino Dev

Blog

Processando CSVs grandes com Node.js Streams sem estourar memória

Por John Pires · 10 de março de 2026

Processando CSVs grandes com Node.js Streams sem estourar memória

Contexto: quando você vai usar isso

Por que CSVs grandes são um problema

Arquivos CSV são onipresentes: exportações de sistemas legados, relatórios, dumps de dados e integrações. O problema aparece quando o arquivo não cabe na memória do servidor. Ler tudo num buffer com fs.readFile significa estourar RAM, matar o processo ou degradar todo o serviço. Para quem está começando, pense em carregar um caminhão de pedras parcialmente desmontado em cima de uma mesa frágil: a mesa quebra. Streams permitem carregar pedra por pedra.

Quando usar streams em vez de ler o arquivo inteiro

Use streams quando o arquivo for grande (megabytes para gigabytes), quando a latência de processamento por linha importar, quando quer inserir linhas em lote numa DB, ou expor uma API que aceita upload e processa sem guardar o arquivo. Se a sua aplicação precisa de random access ao arquivo inteiro, streams não são suficiente por si — mas para transformação linear e ingestão, são ideais.

Streams também trazem backpressure: o consumidor informa ao produtor quanta velocidade consegue processar. Isso evita encher buffers e/ou filas internas.

Explicação visual com diagramas mentais

Fluxo mental simples

Imagine uma esteira transportadora. O arquivo é uma pilha de caixas. A esteira move caixa por caixa, um processador inspeciona cada caixa e coloca o resultado em outra esteira de saída. Se o processo de inspeção for lento, a esteira pode parar para não acumular caixas em excesso. Essa é a essência do backpressure.

Componentes na arquitetura

Os componentes básicos que veremos:

  • Readable stream: leitura do arquivo ou do upload.
  • Transform stream: parse e transformação linha a linha.
  • Writable stream: persistência (arquivo, DB, resposta HTTP).
  • Worker threads (opcional): descarregar CPU-bound.
Tip: stream não é mágica; ainda precisa tratar erros, fechar recursos e respeitar backpressure para evitar vazamentos de memória.

Primeiro exemplo simples

Objetivo do exemplo

Exemplo minimal: ler um CSV grande linha por linha, transformar cada linha em objeto e contar registros. Não usa bibliotecas externas, apenas readline para demonstrar um caminho inicial e seguro em memória.

Código: contagem de linhas com readline

O código abaixo abre um arquivo CSV e processa linha a linha, sem carregar tudo em memória. Funciona bem para arquivos grandes e é um ponto de partida para iniciantes.

// O que este arquivo faz: abre um CSV e conta as linhas processadas, parseando columnas simples
const fs = require('fs');
const readline = require('readline');

async function countCsvLines(path) {
  const stream = fs.createReadStream(path);
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

  let count = 0;
  for await (const line of rl) {
    // processamento simples: ignorar cabeçalho
    if (count === 0) {
      count++;
      continue;
    }
    // aqui você pode fazer parse simples: const cols = line.split(',');
    count++;
  }

  return count - 1; // subtrai cabeçalho
}

countCsvLines('./data/large.csv')
  .then(n => console.log('linhas:', n))
  .catch(err => console.error('erro:', err));

O que acontece depois: o arquivo é lido em blocos, readline separa por quebras de linha e o loop assíncrono processa cada linha de forma sequencial. Memória constante, sem buffers gigantes.

Limitações e quando não usar esse padrão

Este exemplo não trata CSVs com aspas, campos multi-line ou escapes. Para CSVs com complexidade, prefira bibliotecas de parsing streaming que lidam com especificação RFC4180.

Segundo exemplo mais realista

Objetivo do exemplo

Processar um CSV grande com parsing correto, inserir por lotes numa base de dados (simulado) e usar worker threads para transformação CPU-bound. Este exemplo mostra integração com stream pipeline, biblioteca de parsing e um pool simples de workers.

Setup: dependências e cenário

Vamos usar o pacote 'csv-parse' em modo streaming e 'worker_threads' do Node.js. O fluxo será:

  1. Readable stream do arquivo
  2. Parser streaming de CSV
  3. Transform stream que valida e enfileira para workers
  4. Aggregação por batch e escrita simulada

Instale: npm i csv-parse

Código principal: pipeline com parser e batch write

O bloco abaixo é o arquivo principal. Ele monta a pipeline, aceita resultados de workers e escreve por lotes. É funcional com Node.js 14+.

// O que este arquivo faz: parse streaming com csv-parse, usa workers para transformar cada linha e agrupa em batches
const fs = require('fs');
const { pipeline, Transform } = require('stream');
const parse = require('csv-parse');
const { Worker } = require('worker_threads');

const BATCH_SIZE = 1000;

function createWorkerWorker() {
  return new Worker('./row-worker.js'); // veja o código do worker abaixo
}

async function processCsv(path) {
  const parser = parse({ columns: true, relax_quotes: true });

  let batch = [];
  let activeWorkers = [];

  const transformer = new Transform({
    objectMode: true,
    transform(record, enc, cb) {
      // validações rápidas e encaminhamento para worker
      if (!record.id) return cb();

      const worker = createWorkerWorker();
      activeWorkers.push(worker);

      worker.once('message', transformed => {
        batch.push(transformed);
        worker.terminate();
        activeWorkers = activeWorkers.filter(w => w !== worker);
        if (batch.length >= BATCH_SIZE) {
          // simula escrita em DB
          console.log('escrevendo batch', batch.length);
          batch = [];
        }
        cb();
      });

      worker.once('error', err => {
        worker.terminate();
        activeWorkers = activeWorkers.filter(w => w !== worker);
        console.error('worker error', err);
        cb(err);
      });

      worker.postMessage(record);
    }
  });

  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(path);
    pipeline(readStream, parser, transformer, err => {
      if (err) return reject(err);
      // aguardar workers ativos e gravar últimos batchs
      Promise.all(activeWorkers.map(w => w.terminate())).then(() => {
        if (batch.length) console.log('escrevendo último batch', batch.length);
        resolve();
      }).catch(reject);
    });
  });
}

processCsv('./data/large.csv')
  .then(() => console.log('processamento concluído'))
  .catch(err => console.error('falha:', err));

O que acontece depois: o parser emite objetos por linha (columns: true). Para cada record, criamos um worker que processa e retorna um objeto transformado. Em produção você não criaria um worker por linha — ver seção de melhorias abaixo.

Código do worker: transformação CPU-bound

Este é o worker que faz transformação pesada (simulada). Salve como row-worker.js no mesmo diretório.

// O que este arquivo faz: recebe um registro, faz trabalho caro e retorna um objeto transformado
const { parentPort } = require('worker_threads');

function heavyTransform(record) {
  // simula CPU-bound: compressão de campos, cálculos, hashing, ...
  let n = 0;
  for (let i = 0; i < 200000; i++) n += i % 7;
  record._checksum = (n + (record.id ? parseInt(record.id, 10) : 0)).toString(36);
  return record;
}

parentPort.on('message', record => {
  const out = heavyTransform(record);
  parentPort.postMessage(out);
});

O que acontece depois: o worker processa e manda de volta. O main coleta e faz batch write. Com arquivos muito grandes, você deve usar um pool para limitar número de workers simultâneos.

Melhorias práticas

O exemplo acima cria muitos workers em paralelo; isso é ineficiente. Implemente um pool com tamanho fixo, reuse workers e envie tasks sequenciais por worker. Outra melhoria: use writable stream customizado que gerencia batches e backpressure corretamente.

Erros comuns de iniciante e como evitar

1. Ler tudo na memória

Muitos iniciantes usam fs.readFile para facilitar. Isso funciona para arquivos pequenos, mas não para grandes. Sempre prefira createReadStream+parser streaming para grandes volumes. Se receber uploads via HTTP, processe req como stream sem salvar o arquivo no disco.

2. Ignorar backpressure

Ignorar sinais de que o writable está lento causa acúmulo. Use pipeline, respeite retorno do .write e diga pause/resume quando necessário. O util.promisify(stream.pipeline) trata isso automaticamente para fluxos compatíveis.

3. Fazer sync no loop de leitura

Chamadas síncronas pesadas dentro do loop de processamento bloqueiam o event loop e atritam throughput. Para CPU-heavy use worker_threads ou process forks. Para IO-blocking, use operações assíncronas com limite de concorrência.

4. Criar um worker por linha

Criar um worker por registro causa overhead alto. Use um pool de workers com tamanho limitado e fila. Reutilize workers para muitas tarefas pequenas.

5. Não tratar erros e sinais

Não tratar erros de stream, workers ou do parser leva a processos presos. Sempre escute 'error' em streams e 'uncaughtException' como fallback, mas prefira design que não dependa de fallback global.

Dica prática: escreva um script separadamente para testar performance com dados sinteticos; medir é melhor que supor gargalos.

Conclusão prática

Processar CSVs grandes em Node.js é viável e robusto quando você usa streams. A estratégia correta combina leitura streaming, parser compatível com a complexidade do CSV, controle de backpressure e, quando necessário, worker threads para operações CPU-intensive. Para iniciantes: comece com readline para aprender fluxo, depois evolua para csv-parse e um pool de workers quando precisar de parsing preciso e alta performance.

Resumo rápido dos passos:

  • Prefira createReadStream em vez de readFile para arquivos grandes.
  • Use parsers streaming como csv-parse ou fast-csv para lidar com escapes e aspas.
  • Respeite backpressure usando pipeline e streams adequados.
  • Se houver CPU-bound, descarregue em worker_threads com pool.
  • Teste com dados representativos e monitore memória e latência.

Pronto para o próximo passo: implemente um pool de workers, adicione métricas e substitua a simulação de escrita por gravação real em banco com transações em lote. Com essas práticas você evita estouro de memória e mantém throughput previsível.

Assuntos: Node.js, streams, csv, worker-threads, Performance

Leia também