Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Por John Pires · 20 de março de 2026

Motivacao e trade-offs
Por que não carregar tudo na memória
Uploads de arquivos, especialmente imagens e vídeos, crescem em volume e tamanho. Ler um arquivo inteiro na memória para gerar uma thumbnail ou calcular um checksum é uma rota simples, mas perigosa: aumenta o consumo de memória, cria picos GC e torna a aplicação fragilizada frente a ataques de negação de serviço. Para APIs reais é necessário processar fluxos de dados de forma linear e controlar o uso de CPU e memória.
Quando usar worker threads
Operações como compressão, redimensionamento de imagem (libvips/sharp) e hashing de grandes buffers são CPU-bound. Node.js é single-threaded no loop de eventos; ao executar tarefas pesadas no processo principal você atrasa handling de conexões e timers. Worker threads permitem mover o trabalho pesado para threads separadas mantendo o loop de evento responsivo. O trade-off é a complexidade de comunicação e a cópia/transferência de memória entre threads.
As decisões práticas envolvem: (1) streaming para reduzir RAM, (2) duplicação controlada do stream quando precisar de múltiplos consumidores (hash + disco + processamento), (3) offload para workers quando CPU for o gargalo. Em alguns cenários faz sentido combinar: stream para armazenamento imediato + checksum no main thread + thumbnail no worker.
Dica: prefira pipelines e PassThroughs em vez de buffers intermediários. Use arquivos temporários quando o consumo de memória para reconstruir o stream precisar ser evitado.
Fundamentos tecnicos
Streams: duplicar e pipeline
Streams do Node servem para processar dados chunk a chunk. Para alimentar múltiplos consumidores do mesmo upload é comum criar PassThroughs e pipear a origem para várias saídas. Exemplo de padrão: criar duas PassThroughs e direcionar uma para hashing e outra para processamento da thumbnail. Use stream.pipeline (ou a versão promise) para lidar corretamente com erros e fechamento das streams.
Worker Threads: postar mensagens vs transferir ArrayBuffers
Worker threads se comunicam via postMessage. Para evitar cópia de grandes buffers, transfira um ArrayBuffer via transfer list. Quando você tiver um Buffer do Node, convém extrair a porção exata do ArrayBuffer com buffer.buffer.slice(byteOffset, byteOffset + length) para transferir sem surpresas.
Segurança e validação
Antes de processar: valide extensão, tipo MIME (mimetypes são fáceis de falsificar), limite o tamanho do upload, verifique dimensões esperadas e aplique um scanner de malware em uploads críticos. Não confie apenas no nome do arquivo enviado pelo cliente.
Exemplo 1: caso simples
O que vamos fazer
Implementar um endpoint Express que aceita upload via Busboy, grava o arquivo em disco, gera uma thumbnail com sharp e calcula um SHA-256 sem carregar tudo na memória. A estratégia: duplicar o stream com PassThrough e usar pipelines separados.
Código: servidor Express (funcional)
Contexto: este endpoint usa busboy, sharp, stream e crypto. Ele grava o upload em uploads/, calcula o checksum e gera uma thumbnail 200px wide.
const express = require('express');
const fs = require('fs');
const path = require('path');
const Busboy = require('busboy');
const { pipeline, PassThrough, Writable } = require('stream');
const { promisify } = require('util');
const crypto = require('crypto');
const sharp = require('sharp');
const pipe = promisify(pipeline);
const app = express();
app.post('/upload', (req, res) => {
const busboy = new Busboy({ headers: req.headers });
let responded = false;
busboy.on('file', (fieldname, file, info) => {
const filename = path.basename(info.filename);
const uploadPath = path.join(__dirname, 'uploads', filename);
// duplicadores de stream
const passForHash = new PassThrough();
const passForSharp = new PassThrough();
// encaixar duplicação
file.pipe(passForHash);
file.pipe(passForSharp);
// sink para salvar arquivo
const writeStream = fs.createWriteStream(uploadPath);
// hash writable
const hash = crypto.createHash('sha256');
const hashSink = new Writable({
write(chunk, enc, cb) { hash.update(chunk); cb(); }
});
// pipeline para salvar e criar thumbnail
Promise.all([
pipe(passForHash, hashSink),
pipe(passForSharp, sharp().resize(200).jpeg(), fs.createWriteStream(uploadPath + '.thumb.jpg')),
pipe(file, writeStream) // garante que file também vá para disco (opcional)
]).then(() => {
const digest = hash.digest('hex');
if (!responded) { responded = true; res.json({ ok: true, filename, sha256: digest }); }
}).catch(err => {
if (!responded) { responded = true; res.status(500).json({ error: err.message }); }
});
});
busboy.on('error', err => res.status(500).json({ error: err.message }));
req.pipe(busboy);
});
app.listen(3000);
O que acontece: o arquivo enviado é duplicado para hashing e para o pipeline do sharp. Usamos pipelines separados para automatizar o tratamento de erros. Evitamos armazenar o arquivo completo na memória.
Exemplo 2: caso avancado do mundo real
O desafio real
Em produção você pode ter uploads grandes e múltiplas transformações (várias resoluções de thumbnails, WebP conversion, validação e assinatura de conteúdo). Aqui é preferível gravar o upload em disco (ou S3 multipart) e delegar o processamento pesado para uma pool de workers, controlando concorrência.
Estratégia adotada
Nesta abordagem: (1) stream para um arquivo temporário enquanto calculamos um checksum rápido; (2) ao finalizar, enviamos o caminho para um worker thread que abre o arquivo, executa sharp para gerar todas as thumbnails e retorna buffers transferidos de volta (ou escreve em um storage externo).
Código: main thread (upload + delegação ao worker)
Contexto: este script grava em disco e posta a tarefa para um worker localizado em worker.js. O main thread mantém a responsividade e só espera o trabalho assíncrono terminar para atualizar o banco.
// main.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const Busboy = require('busboy');
const crypto = require('crypto');
const { Worker } = require('worker_threads');
const { pipeline, PassThrough, Writable } = require('stream');
const { promisify } = require('util');
const pipe = promisify(pipeline);
const app = express();
app.post('/upload', (req, res) => {
const busboy = new Busboy({ headers: req.headers });
busboy.on('file', (fieldname, file, info) => {
const filename = `${Date.now()}-${path.basename(info.filename)}`;
const tmpPath = path.join(__dirname, 'tmp', filename);
const writeStream = fs.createWriteStream(tmpPath);
const hash = crypto.createHash('sha256');
const hashSink = new Writable({ write(chunk, enc, cb) { hash.update(chunk); cb(); } });
// duplicar stream para salvar + hash
const passForHash = new PassThrough();
file.pipe(passForHash);
file.pipe(writeStream);
pipe(passForHash, hashSink).then(() => {
const digest = hash.digest('hex');
// Delegar para worker
const worker = new Worker(path.join(__dirname, 'worker.js'));
worker.once('message', msg => {
// msg => { thumbs: [{name, buffer, length}], info }
// reconstrói buffers a partir de ArrayBuffers transferidos
for (const t of msg.thumbs) {
const ab = t.buffer; // já é ArrayBuffer
const buf = Buffer.from(ab);
fs.writeFileSync(path.join(__dirname, 'uploads', t.name), buf);
}
fs.unlinkSync(tmpPath);
res.json({ ok: true, sha256: digest, thumbs: msg.thumbs.map(t => t.name) });
worker.terminate();
});
worker.postMessage({ filePath: tmpPath, tasks: [{ width: 200, suffix: '200' }, { width: 800, suffix: '800' }] });
}).catch(err => {
res.status(500).json({ error: err.message });
});
});
req.pipe(busboy);
});
app.listen(3000);
O que acontece: o upload é salvo em tmp; em paralelo calculamos o SHA-256. Quando o hash finaliza, criamos um worker e pedimos para gerar thumbs. Usamos escrita em disco no worker para reduzir a transferência de dados entre threads, mas também mostramos como transferir buffers se necessário.
Código: worker (worker.js)
Contexto: o worker abre o arquivo no disco, gera thumbnails usando sharp e envia os buffers de volta ao main thread como ArrayBuffers transferidos para evitar cópia desnecessária.
// worker.js
const { parentPort } = require('worker_threads');
const fs = require('fs');
const sharp = require('sharp');
parentPort.on('message', async (msg) => {
const { filePath, tasks } = msg;
const results = [];
try {
for (const t of tasks) {
const buf = await sharp(filePath).resize({ width: t.width }).toFormat('jpeg').toBuffer();
// extrai ArrayBuffer exato para transferir
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
results.push({ name: `${path.basename(filePath)}.${t.suffix}.jpg`, buffer: ab, length: buf.length });
}
parentPort.postMessage({ thumbs: results });
} catch (err) {
parentPort.postMessage({ error: err.message });
}
});
O que acontece: o worker gera os buffers e os envia de volta. No main thread reconstruímos os Buffers a partir do ArrayBuffer e gravamos os arquivos finais. Isso evita que o main thread faça processamento de imagem e mantém o loop de eventos livre.
Debugging e troubleshooting
Erros comuns e armadilhas
Listagem dos problemas que aparecem com frequência:
- Perda de dados ao pipear múltiplos consumidores sem PassThrough; resultado: apenas um consumidor recebe dados.
- Fazer
Buffer.from(ab)com um ArrayBuffer maior que a fatia correta — gere resultados corrompidos se não slicear corretamente o ArrayBuffer. - Esquecer de lidar com erros em pipelines: um erro em uma das streams pode deixar a requisição pendente. Use
stream.pipelineou o evento'error'. - Registrar thumbnails grandes sem controlar concorrência — em picos isso pode criar fila de tasks e exaurir CPU.
Logs e introspecção
Como debugar: adicione logs estruturados nos pontos de entrada/saída das etapas: início do upload, término de gravação em tmp, envio ao worker, término do worker. Em workers, capture exceções não tratadas com process.on('uncaughtException') para enviar informação de volta ao main thread antes de terminar. Use métricas (latência por upload, uso de CPU por worker) para ajustar pool de workers.
Dicas práticas
Pool de workers: crie uma pool limitada em vez de instanciar um worker por upload para evitar escalonamento excessivo. Uma pool de tamanho igual ao número de CPUs ou menos costuma funcionar bem. Gerencie fila de tarefas com backpressure.
Dica: prefira escrever thumbails direto para storage externo (S3) a partir do worker quando possível — isso elimina cópias adicionais e passo de disco no main thread.
Conclusão prática
Resumo das recomendações
Combine streams e worker threads: use streams para reduzir uso de memória e duplicar dados via PassThrough apenas quando necessário; use worker threads para trabalhos CPU-bound como processamento de imagens; prefira transferência de ArrayBuffers ao enviar grandes dados entre threads para minimizar cópias.
Checklist antes de produção
- Limitar tamanho máximo de upload e tempo máximo por upload.
- Validar tipo de arquivo e aplicar verificação adicional (magic bytes).
- Ter pool de workers e limites de concorrência para thumbnails.
- Registrar métricas de latência e uso de recursos.
Implementações que respeitam esses pontos mantêm a API responsiva e segura, mesmo sob carga. A arquitetura combinando streams com worker threads é prática, escalável e relativamente simples de operar quando estruturada por pipelines e com boa observabilidade.
Este artigo apresentou padrões aplicáveis ao dia a dia: duplicação de streams com PassThrough, pipelines com tratamento de erro, gravação em disco como fallback e offload de CPU usando worker threads com transferência controlada de ArrayBuffers. Esses padrões reduzem riscos de OOM, mantêm o loop responsivo e permitem processar uploads de forma robusta.
Assuntos: Node.js, streams, worker-threads, Performance, Segurança
Leia também
Health checks e readiness probes em containers Docker para Node.js: passo a passo
Guia prático para colocar health checks e readiness probes em Node.js com Docker, docker-compose, Kubernetes e GitHub Actions. Passo a passo, erros comuns e…
Paralelizando resolvers CPU-bound no Apollo/GraphQL com Worker Threads e pool
Evite bloqueio do event loop em resolvers CPU-bound no Apollo/GraphQL usando worker_threads com pool. Guia prático com implementação, integração com Apollo…
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…
Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
Processar CSV grandes sem travar o Node: mostro o jeito errado (ler tudo) e a solução correta com streams, worker threads e SSE para progresso em tempo real…
Diagnóstico de latência em APIs Node.js com async_hooks, event-loop e GC
Diagnostique latência em APIs Node.js combinando async_hooks/AsyncLocalStorage, monitorEventLoopDelay e análise de GC. Passo a passo prático: medir…