Uma das grandes vantagens do Node.js é sua capacidade de lidar com milhares de conexões simultâneas utilizando um modelo baseado em eventos e operações assíncronas.
Uma das grandes vantagens do Node.js é sua capacidade de lidar com milhares de conexões simultâneas utilizando um modelo baseado em eventos e operações assíncronas.
Porém, essa facilidade também pode esconder problemas de consumo excessivo de memória quando a aplicação cresce.
É comum encontrar aplicações que funcionam perfeitamente durante os testes iniciais, mas começam a apresentar lentidão, pausas frequentes do Garbage Collector ou até mesmo falhas por falta de memória quando passam a processar grandes volumes de dados.
Muitas dessas situações não estão relacionadas à quantidade de requisições recebidas, mas sim à forma como os dados são carregados e mantidos em memória.
Este projeto demonstra dois cenários extremamente comuns em aplicações Node.js:
Além disso, apresenta abordagens mais eficientes utilizando Streams, cursores e processamento em lotes.
Antes de analisar os cenários, é importante entender alguns conceitos.
O Node.js utiliza o mecanismo V8, responsável por gerenciar a memória da aplicação.
Entre os principais indicadores estão:
Local onde objetos JavaScript são armazenados.
Exemplo:
const users = [];
Todos os objetos adicionados ao array ocupam espaço na Heap.
Representa toda a memória ocupada pelo processo.
Inclui:
Por isso normalmente o RSS é maior que a Heap.
O projeto foi criado para responder uma pergunta muito comum:
Como processar grandes volumes de dados sem consumir centenas de megabytes de memória?
Para isso foram simulados dois cenários reais encontrados frequentemente em sistemas corporativos.
Imagine um sistema que precisa importar arquivos CSV enviados pelos usuários.
Exemplo:
id,nome,email
1,João,joao@email.com
2,Maria,maria@email.com
...
100000 registros
Uma implementação ingênua pode funcionar perfeitamente em pequenos arquivos, mas se tornar um problema conforme os dados aumentam.
A abordagem mais comum costuma ser:
const content = fs.readFileSync('users.csv', 'utf8');
const lines = content.split('\n');
O que acontece internamente?
Arquivo
↓
Buffer completo
↓
String completa
↓
Array de linhas
↓
Objetos JavaScript
Durante esse processo existem múltiplas cópias dos dados em memória simultaneamente.
Quanto maior o arquivo, maior o impacto.
| Métrica | Antes |
|---|---|
| Pico Heap | 69.48 MB |
| Pico RSS | 139.63 MB |
Uma alternativa muito mais eficiente é utilizar Streams.
Exemplo:
const stream = fs.createReadStream('users.csv');
Combinado com:
readline.createInterface(...)
o processamento ocorre linha por linha.
Arquivo
↓
Linha 1
Processa
Descarta
Linha 2
Processa
Descarta
Linha 3
Processa
Descarta
A quantidade de memória utilizada deixa de depender do tamanho total do arquivo.
| Métrica | Depois |
|---|---|
| Pico Heap | 7.75 MB |
| Pico RSS | 63.08 MB |
| Métrica | Economia |
|---|---|
| Heap | 88.8% |
| RSS | 54.8% |
A principal vantagem é que apenas uma pequena parte do arquivo permanece em memória por vez.
Mesmo que o arquivo cresça para:
10 GB
50 GB
100 GB
o consumo de memória continua praticamente constante.
Outro problema muito comum ocorre durante consultas massivas.
Imagine uma tabela contendo:
100.000 registros
ou até:
1.000.000 registros
Muitos desenvolvedores utilizam algo semelhante a:
const users = await repository.findAll();
ou
SELECT * FROM users;
O banco retorna todos os registros de uma única vez.
O fluxo normalmente é:
Banco
↓
100.000 linhas
↓
Objetos JS
↓
Heap
Todo o conjunto precisa permanecer em memória simultaneamente.
| Métrica | Antes |
|---|---|
| Pico Heap | 63.50 MB |
| Pico RSS | 129.25 MB |
Uma abordagem mais eficiente consiste em processar pequenos grupos de registros.
Exemplo:
for await (const batch of cursor) {
process(batch);
}
ou utilizando AsyncGenerator:
async function* getUsers() {
...
}
Banco
↓
1000 registros
Processa
Descarta
1000 registros
Processa
Descarta
1000 registros
Processa
Descarta
O Garbage Collector pode liberar memória continuamente.
| Métrica | Depois |
|---|---|
| Pico Heap | 10.65 MB |
| Pico RSS | 63.75 MB |
| Métrica | Economia |
|---|---|
| Heap | 83.2% |
| RSS | 50.7% |
Quando grandes estruturas permanecem em memória:
Array gigantesco
Objetos gigantescos
Strings gigantescas
o Garbage Collector precisa trabalhar mais.
Isso provoca:
Em aplicações web isso pode impactar diretamente o tempo de resposta para o usuário.
Streams são recomendados para:
Sempre que possível, evite carregar arquivos inteiros na memória.
Cursores são recomendados para:
Sempre que o volume de dados puder crescer significativamente.
Alguns sintomas comuns:
200 MB
300 MB
500 MB
800 MB
sem retornar ao valor inicial.
Logs contendo:
Mark-Sweep
Scavenge
Allocation Failure
com alta frequência.
Erro clássico:
FATAL ERROR:
JavaScript heap out of memory
Mesmo sem aumento de CPU ou banco de dados.
Muitas vezes o problema está no gerenciamento da memória.
Evite:
findAll()
em tabelas muito grandes.
Prefira:
Paginação
Cursores
Streams
Evite:
fs.readFileSync()
para arquivos grandes.
Prefira:
createReadStream()
Monitore constantemente:
process.memoryUsage()
para identificar comportamentos anormais.
| Cenário | Economia Heap | Economia RSS |
|---|---|---|
| Arquivos CSV | 88.8% | 54.8% |
| Banco de Dados | 83.2% | 50.7% |
Os números demonstram que pequenas mudanças arquiteturais podem reduzir drasticamente o consumo de memória sem necessidade de aumentar recursos da infraestrutura.
O consumo excessivo de memória em aplicações Node.js raramente está relacionado apenas ao volume de usuários.
Na maioria dos casos, o problema está na forma como os dados são carregados e processados.
A utilização de Streams para arquivos e Cursores para consultas de banco permite trabalhar com grandes volumes de dados mantendo um consumo previsível e controlado de memória.
Os resultados obtidos neste projeto mostram reduções superiores a 80% no uso da Heap, evidenciando como decisões arquiteturais simples podem aumentar significativamente a escalabilidade e estabilidade de aplicações Node.js.
Mais do que otimizar memória, essas técnicas ajudam a construir sistemas capazes de crescer sem exigir constantemente mais CPU, mais RAM ou servidores maiores.