Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
Por John Pires · 20 de março de 2026

Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
1) Introducao ao problema
O que acontece quando você lê tudo na memória
Uma abordagem comum entre iniciantes é aceitar um upload, ler todo o arquivo para memória e depois parseá-lo de uma vez. Em Node.js isso parece simples: você usa multer com memoryStorage ou fs.readFile, transforma o conteúdo em string, passa para um parser síncrono e pronto. Funciona para arquivos pequenos, mas falha rapidamente quando o CSV cresce: memória estoura, GC trava a aplicação e o processo pode morrer com OOM.
Analogia simples: a esteira vs a caixa
Pense no processamento de CSV como uma fábrica. Ler o arquivo inteiro é como guardar todo o material em uma caixa gigante antes de processar — a caixa enche e bloqueia o fluxo. Usar streams é colocar uma esteira: cada linha passa, é processada e removida. Worker Threads são os operários que fazem o trabalho pesado sem travar o operador da esteira (o event loop). SSE é a placa de status que mostra o progresso em tempo real à equipe.
Antes de ver código correto, vou mostrar o jeito errado — porque é importante reconhecer padrões que quebram em produção. Depois do erro, mostro a correção com streams, workers e SSE.
2) Pre-requisitos e setup
Ferramentas e versões
Para acompanhar os exemplos você precisa de: Node.js 14+ (ideal 16+), npm/yarn, e conhecimento básico de JavaScript e linha de comando. Também usaremos o Express para endpoints HTTP. Dependências sugeridas: busboy (streaming multipart), csv-parse (parser streaming opcional), ou apenas readline nativo para CSV simples.
Estrutura mínima do projeto
Crie um projeto com package.json e instale as dependências. Exemplo mínimo de comandos:
mkdir csv-stream-workers && cd csv-stream-workers
npm init -y
npm i express busboy
Vamos usar a API nativa de worker_threads, então não precisa instalar nada extra para isso.
Considerações de segurança
Uploads abrirem uma superfície de ataque: limite tamanho máximo por upload, valide o tipo de conteúdo, autentique quem pode enviar e sanitize qualquer dado antes de inserir em banco. Evite expor a rota SSE sem autenticação quando necessário. Use HTTPS em produção para proteger SSE e uploads.
3) Tutorial guiado passo a passo
3.1 Exemplo errado: ler tudo em memória (anti-pattern)
Primeiro, o erro clássico. Abaixo um exemplo com multer memoryStorage que lê tudo e processa sincronicamente. Isso parece simples, mas é perigoso para arquivos grandes.
Contexto: este código aceita um upload e tenta parsear todo o CSV para memória usando split por newline.
// server-wrong.js
const express = require('express')
const multer = require('multer')
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } })
const app = express()
app.post('/upload-wrong', upload.single('file'), (req, res) => {
// Erro: carrega o arquivo inteiro na memória
const buffer = req.file.buffer
const content = buffer.toString('utf8')
const rows = content.split('\n').map(r => r.split(','))
// Simula trabalho pesado síncrono
let processed = 0
for (const row of rows) {
// processamento pesado simulado
processed += row.length
}
res.json({ rows: rows.length, processed })
})
app.listen(3000)
O que acontece: funciona para alguns MBs, mas se o arquivo tiver centenas de MBs ou GBs sua aplicação vai consumir muita memória e o event loop pode travar durante o parsing síncrono.
3.2 Por que é ruim (erros comuns e armadilhas)
Erros comuns ao seguir essa abordagem:
- memoryStorage aceita arquivos grandes e os coloca em Buffer na memória
- split('\n') cria um array com todas as linhas — grande overhead
- processamento síncrono bloqueia o event loop
- sem limites/validações, um atacante pode enviar vários arquivos e causar DoS
Dica: se você precisa de upload multi-GB, nunca use armazenamento em memória. Sempre use streaming para processar por partes.
3.3 Correção: receber o upload como stream
Agora o jeito certo: usar busboy (ou o parser de multipart do framework) para ler o arquivo como stream. Vou mostrar um endpoint que aceita o upload e encaminha o stream para um parser linha a linha usando readline. Isso evita armazenar tudo em memória.
Contexto: o código abaixo lê o arquivo enviado multipart/form-data e faz parse por linha do CSV com readline do Node.
// server-stream.js
const express = require('express')
const Busboy = require('busboy')
const readline = require('readline')
const { Worker } = require('worker_threads')
const { v4: uuidv4 } = require('uuid')
const app = express()
const jobs = new Map() // guarda estado para SSE
app.post('/upload', (req, res) => {
const busboy = new Busboy({ headers: req.headers })
const jobId = uuidv4()
jobs.set(jobId, { processed: 0, total: 0 })
busboy.on('file', (fieldname, file, filename) => {
// file é um stream legível
const rl = readline.createInterface({ input: file })
const worker = new Worker('./worker.js')
const batch = []
const BATCH_SIZE = 1000
rl.on('line', (line) => {
jobs.get(jobId).total++
batch.push(line)
if (batch.length >= BATCH_SIZE) {
worker.postMessage({ lines: batch.splice(0, batch.length) })
}
})
rl.on('close', () => {
if (batch.length) worker.postMessage({ lines: batch.splice(0, batch.length) })
worker.postMessage({ done: true })
})
worker.on('message', (msg) => {
if (msg.processed) {
jobs.get(jobId).processed += msg.processed
}
})
worker.on('exit', () => {
// worker terminou
})
})
busboy.on('finish', () => {
res.json({ jobId })
})
req.pipe(busboy)
})
app.listen(3000)
O que acontece: o arquivo não é armazenado na memória. readline entrega linhas uma a uma. Enviamos batches para um Worker Thread que faz o processamento pesado em paralelo sem bloquear o event loop.
3.4 O Worker Thread: processamento sem bloquear
Contexto: o worker recebe batches de linhas, parseia e faz trabalho intensivo (ex: validação, transformação). O resultado pode ser armazenado em banco ou enviado de volta ao main thread como notificações de progresso.
// worker.js
const { parentPort } = require('worker_threads')
let processed = 0
parentPort.on('message', (msg) => {
if (msg.lines) {
for (const line of msg.lines) {
// parse simples CSV (não robusto contra vírgulas em quotes)
const cols = line.split(',')
// processamento CPU-bound simulado
processed += cols.length
}
parentPort.postMessage({ processed: msg.lines.length })
}
if (msg.done) {
parentPort.postMessage({ done: true, processed })
process.exit(0)
}
})
O que acontece: o worker calcula e envia de volta quantas linhas processou. O main thread atualiza o estado do job (jobs Map) usado pelo SSE para notificar clientes.
3.5 Progresso em tempo real com SSE
Contexto: SSE (Server-Sent Events) é uma conexão unidirecional do servidor para o cliente que mantém um stream de eventos. É simples de implementar: o cliente abre uma EventSource e o servidor escreve linhas no formato "data: ...\n\n".
// server-sse.js (trecho do mesmo app)
app.get('/events/:jobId', (req, res) => {
const jobId = req.params.jobId
const state = jobs.get(jobId)
if (!state) return res.status(404).end()
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
})
const interval = setInterval(() => {
const s = jobs.get(jobId)
if (!s) return
res.write(`data: ${JSON.stringify({ processed: s.processed, total: s.total })}\n\n`)
if (s.total > 0 && s.processed >= s.total) {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`)
clearInterval(interval)
// opcional: jobs.delete(jobId)
res.end()
}
}, 500)
req.on('close', () => clearInterval(interval))
})
O que acontece: o cliente que abriu /events/:jobId recebe atualizações regulares sobre processed/total. Quando terminado, o servidor envia done e fecha a conexão.
4) Resultado final funcionando
Exemplo completo (server.js) — como juntar tudo
Aqui temos um resumo de como unir upload stream, Worker Threads e SSE numa única aplicação Express. Código reduzido, funcionando, pronto para testes locais.
// server-final.js (resumo funcional)
const express = require('express')
const Busboy = require('busboy')
const readline = require('readline')
const { Worker } = require('worker_threads')
const { v4: uuidv4 } = require('uuid')
const app = express()
const jobs = new Map()
app.post('/upload', (req, res) => {
const busboy = new Busboy({ headers: req.headers })
const jobId = uuidv4()
jobs.set(jobId, { processed: 0, total: 0 })
busboy.on('file', (fieldname, file) => {
const rl = readline.createInterface({ input: file })
const worker = new Worker('./worker.js')
const batch = []
const BATCH_SIZE = 1000
rl.on('line', (line) => {
const state = jobs.get(jobId)
state.total++
batch.push(line)
if (batch.length >= BATCH_SIZE) worker.postMessage({ lines: batch.splice(0, BATCH_SIZE) })
})
rl.on('close', () => {
if (batch.length) worker.postMessage({ lines: batch.splice(0, batch.length) })
worker.postMessage({ done: true })
})
worker.on('message', (msg) => {
if (msg.processed) jobs.get(jobId).processed += msg.processed
})
})
busboy.on('finish', () => res.json({ jobId }))
req.pipe(busboy)
})
app.get('/events/:jobId', (req, res) => {
const jobId = req.params.jobId
if (!jobs.has(jobId)) return res.status(404).end()
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
})
const interval = setInterval(() => {
const s = jobs.get(jobId)
if (!s) return
res.write(`data: ${JSON.stringify({ processed: s.processed, total: s.total })}\n\n`)
if (s.total > 0 && s.processed >= s.total) {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`)
clearInterval(interval)
res.end()
}
}, 500)
req.on('close', () => clearInterval(interval))
})
app.listen(3000)
O que acontece: esse arquivo combina as ideias mostradas antes. Em produção você deve tratar erros, logs, limites e autenticação.
Cliente JavaScript para SSE
Contexto: cliente abre EventSource para receber progresso e pode usar fetch para enviar o arquivo. Exemplo de cliente que mostra progresso no console.
// client.js (browser)
// enviar com fetch + FormData
async function upload(file) {
const fd = new FormData()
fd.append('file', file)
const r = await fetch('/upload', { method: 'POST', body: fd })
const { jobId } = await r.json()
const es = new EventSource(`/events/${jobId}`)
es.onmessage = (e) => {
const data = JSON.parse(e.data)
console.log('progress', data)
if (data.done) es.close()
}
}
O que acontece: o navegador mostra atualizações periódicas do servidor até a tarefa terminar. SSE reconecta automaticamente em desconexões curtas, mas atenção a autenticação e headers CORS.
Erros comuns ao integrar tudo
Lista de problemas que você pode encontrar ao montar o sistema:
- Não fechar worker corretamente — pode gerar vazamento de processos
- Enviar muitas mensagens pequenas entre main e worker — overhead de IPC
- Ler linhas com readline pode quebrar se o CSV tem quebras dentro de quotes — use parser robusto se necessário
- Não validar conteúdo do CSV antes de inserir em DB — vulnerabilidade de SQL injection/CSV injection
Dica: prefira batches maiores para reduzir mensagens IPC e medir latência entre envio e confirmação.
5) Desafio para o leitor praticar
Desafio 1 — adicionar validação e inserção em banco
Modifique o worker para validar cada linha segundo um esquema (por exemplo: id:number, email:string) e, para linhas válidas, acumulá-las para inserir em batch no PostgreSQL usando COPY ou inserções em lote. Meça o throughput.
Desafio 2 — CSV com campos entre aspas e vírgulas internas
Substitua o parse simples por um parser robusto (ex: csv-parse em modo stream) dentro do worker. Compare a performance e documente trade-offs entre robustez e velocidade.
Desafio 3 — autenticação SSE e reconexão
Proteja a rota /events/:jobId com JWT e implemente lógica de reconexão no cliente para recuperar o estado do job caso o cliente perca a conexão. Considere usar ETag/last-event-id se necessário.
Conclusao pratica
Resumo das escolhas técnicas
Para processar grandes CSV em Node.js sem travar a aplicação, combine: (1) leitura por streams para evitar carregar tudo em memória, (2) delegar CPU-bound para Worker Threads para não bloquear o event loop e (3) expor progresso com SSE para feedback em tempo real. Cada peça resolve um problema específico: memória, bloqueio e UX.
Checklist para produção
- Limites de upload e autenticação nas rotas
- Validação e escaping antes da persistência
- Gestão de workers (pooling) e políticas de retry
- Monitoramento de uso de memória e CPU
Implementar corretamente esses padrões reduz risco de incidentes e melhora a experiência do usuário. Comece pequeno: transforme o anti-pattern em streaming, depois adicione worker e SSE. Teste com arquivos reais e meça — sempre meça.
Se precisar, posso fornecer um repositório inicial completo com scripts de teste (gerador de CSV), configuração de pool de workers e integração com PostgreSQL. Peça o repositório e eu gero os arquivos prontos para rodar.
Assuntos: Node.js, streams, worker-threads, sse, csv, Performance, express
Leia também
Rotação segura de refresh tokens JWT: detectar reuse e revogar sessões
Padrão prático para rotacionar refresh tokens JWT com detecção de reuse: como modelar sessões no Redis, atualizar currentJti de forma atômica, revogar sessões…
Webhooks idempotentes em Node/Express: deduplicação, verificação e retries
Guia prático para implementar webhooks idempotentes em Node/Express: validação HMAC sobre raw body, deduplicação com Redis, persistência atômica e retries com…
Worker Threads: sandbox seguro para executar código de usuário no Node.js
Implementar sandbox seguro para executar código de usuário em Node.js: use worker threads + vm, limite memória com execArgv, aplique timeout externo, evite…
Processando CSVs grandes com Node.js Streams sem estourar memória
Guia passo a passo para processar CSVs grandes em Node.js sem estourar memória: exemplos práticos com streams, csv-parse, readline e worker threads, focado em…
Abortar pipelines de Streams em Node.js: AbortController, timeouts e limpeza de Workers
Guia prático para abortar pipelines de streams em Node.js: uso de AbortController, patterns de timeout, e limpeza segura de Worker Threads para evitar…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…