Otimizando Memória no Node.js: Heap, Stack e Garbage Collection
O consumo de memória é um dos fatores que mais impactam estabilidade e custo de aplicações em produção. No Node.js, isso é ainda mais relevante porque muitos serviços rodam por longos períodos, atendendo múltiplas requisições e mantendo caches, filas e conexões abertas. Quando a memória cresce de forma descontrolada, os sintomas variam de lentidão e pausas do Garbage Collector (GC) até crashes por out of memory.
Este guia explica, de forma prática e baseada no funcionamento do runtime (V8), como heap, stack e garbage collection se comportam no Node.js e quais estratégias ajudam a diagnosticar e otimizar o uso de RAM com segurança.
Visão geral: como o Node.js usa memória
O Node.js é construído sobre o motor JavaScript V8, que gerencia boa parte da memória associada a objetos JavaScript. De forma simplificada, é útil separar em:
- Stack (pilha): memória usada para execução de funções e armazenamento de valores locais e endereços de retorno.
- Heap: onde vivem objetos, strings e estruturas dinâmicas do JavaScript (arrays, maps, closures etc.).
- Memória “fora do heap”: buffers e estruturas nativas (por exemplo,
Buffer, alocações internas do runtime e bibliotecas nativas). Parte disso aparece como external memory no V8.
Na prática, quando você observa “RAM do processo” no sistema operacional, está olhando o total: heap + fora do heap + overhead do processo.
Stack no Node.js: quando a pilha vira um problema
A stack armazena quadros de execução (call frames). Ela é rápida, mas tem tamanho limitado. O problema clássico é o estouro de pilha:
RangeError: Maximum call stack size exceeded
Causas comuns
- Recursão profunda sem condição de parada adequada.
- Recursão com dados grandes (por exemplo, percorrendo árvore/JSON muito profundo).
- Bibliotecas que recursivamente processam estruturas.
Como mitigar
- Prefira abordagens iterativas para percursos profundos.
- Para árvores, use uma pilha manual:
function traverse(root) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
// processa node
if (node.children) {
for (const child of node.children) stack.push(child);
}
}
}
Observação importante: aumentar a stack não é uma estratégia típica no Node.js. O caminho mais seguro é ajustar o algoritmo.
Heap no Node.js: onde a maior parte dos vazamentos acontece
O heap é a área de memória dinâmica gerenciada pelo V8. Objetos permanecem no heap enquanto ainda houver referências alcançáveis a eles (diretas ou indiretas). Quando uma referência fica “presa” sem necessidade, ocorre o cenário mais comum em produção: memory leak (vazamento de memória).
Limites e comportamento
O V8 impõe limites ao heap por padrão, que podem variar por versão/arquitetura, mas a regra geral é: o processo não pode crescer indefinidamente. Quando o heap se aproxima do limite, o GC trabalha mais, gerando pausas maiores. Se não conseguir liberar memória suficiente, o processo pode falhar com erro de falta de memória.
Você pode ajustar o limite com:
node --max-old-space-size=4096 app.js
Isso aumenta a área da old generation para ~4 GB (valor em MB). É útil em casos específicos, mas não substitui correção de vazamentos: só adia o problema e pode aumentar a pressão do GC.
Garbage Collection (GC): como funciona e por que afeta performance
O V8 usa um GC geracional. A ideia central:
- Young generation: objetos recém-criados. Coleta frequente e relativamente rápida.
- Old generation: objetos que sobrevivem a coletas e “envelhecem”. Coleta menos frequente, porém potencialmente mais cara.
O que piora o GC
- Alta taxa de alocação: criar muitos objetos por segundo (ex.: map/reduce em grande volume, criação de objetos temporários).
- Objetos de vida longa: caches sem controle, mapas globais que só crescem, dados anexados ao
req/rese retidos. - Fragmentação e grande volume na old generation: aumenta custo de marcação/varredura e compaction.
Sintomas típicos
- Latência oscilando em “dentes de serra”.
- Uso de CPU aumentado sem tráfego proporcional.
- RAM crescente ao longo do tempo.
- Pausas perceptíveis em endpoints críticos.
Causas frequentes de vazamento de memória em Node.js
Abaixo estão padrões comuns e verificáveis em serviços Node.js.
1) Cache sem política de expiração
Exemplo clássico: Map global que só cresce.
const cache = new Map();
function getUser(id) {
if (cache.has(id)) return cache.get(id);
const user = fetchUserFromDB(id);
cache.set(id, user);
return user;
}
Se o conjunto de id for grande e não houver expiração/limite, o heap cresce continuamente.
Mitigação: use TTL e limites (LRU). A lógica pode ser implementada na aplicação ou via biblioteca consolidada.
2) Listeners não removidos (EventEmitter)
Vazamentos podem ocorrer quando listeners acumulam sem off/removeListener, especialmente em objetos de longa vida.
Sintoma comum: warning de MaxListenersExceededWarning.
Mitigação: garanta remoção em fluxos de curta duração e evite registrar listeners repetidamente no mesmo objeto.
3) Promises e filas que retêm referências
- Arrays de tarefas que nunca são esvaziados.
- Filas que guardam closures com referências grandes.
- Rejeições não tratadas que impedem limpeza lógica.
Mitigação: desenhe filas com backpressure, limite de concorrência e descarte/timeout.
4) Retenção acidental via closures
Closures podem manter referências a objetos grandes mesmo após “não serem mais necessários”.
Mitigação: evite capturar estruturas grandes em funções internas se elas deveriam ser descartadas; extraia dados mínimos necessários.
5) Buffer/Streams mal gerenciados
Em Node.js, Buffer e streams podem consumir memória significativa fora do heap.
Mitigação:
- Prefira streaming a carregar tudo em memória.
- Configure limites (por exemplo, tamanho máximo de upload).
- Atente a
highWaterMarke à aplicação correta de backpressure (pipeline,await once(stream, 'drain')etc.).
Passo a passo: diagnosticando memória no Node.js de forma prática
1) Meça antes de mudar
Colete métricas consistentes:
- RSS do processo (memória total).
- Heap usado e heap total (V8).
- Event loop lag e latência de requisições.
- Taxa de GC (quando disponível via ferramentas).
Um ponto de partida dentro do próprio processo:
setInterval(() => {
const m = process.memoryUsage();
console.log({
rss: Math.round(m.rss / 1024 / 1024) + "MB",
heapUsed: Math.round(m.heapUsed / 1024 / 1024) + "MB",
heapTotal: Math.round(m.heapTotal / 1024 / 1024) + "MB",
external: Math.round(m.external / 1024 / 1024) + "MB",
});
}, 10000);
Interpretação rápida:
- heapUsed subindo sem cair após ciclos de carga pode indicar retenção.
- external alto sugere buffers/uso nativo relevante.
- rss pode continuar alto mesmo após GC por conta de alocação/fragmentação e comportamento do alocador do sistema.
2) Reproduza o crescimento em ambiente controlado
- Use um cenário de carga (staging/local) que simule tráfego.
- Observe se a memória cresce de forma linear com o tempo.
- Tente reduzir variáveis: mesma rota, mesmos payloads, mesmo volume.
3) Gere heap snapshots e compare
O método mais eficaz para identificar vazamentos no heap é comparar snapshots ao longo do tempo e procurar por objetos que crescem e permanecem retidos.
Estratégia prática:
- Inicie o processo.
- Aplique carga por um período.
- Tire snapshot.
- Repita (após mais carga).
- Compare dominadores (retainers) e classes/constructors que mais crescem.
Ferramentas comuns:
- DevTools do Node (inspeção/heap snapshot).
- Profilers de memória compatíveis com V8.
O foco aqui é encontrar o caminho de retenção: quem está segurando a referência que impede o GC.
4) Diferencie heap leak de “external/native growth”
Se heapUsed estabiliza, mas rss e external sobem, investigue:
- Buffers acumulados (uploads, downloads, filas).
- Streams sem consumo.
- Bibliotecas nativas com caches internos.
- Tamanho de payload e compressão.
Técnicas de otimização que costumam funcionar
Reduza alocação de objetos temporários
Em rotas quentes, evite padrões que criem muitos objetos intermediários. Exemplo: múltiplos map/filter/reduce em listas grandes em cada requisição. Às vezes, um loop simples reduz pressão no GC.
Faça caching com governança
Cache é útil, mas precisa de:
- TTL
- limite máximo
- política de descarte (LRU/LFU)
- métricas (hit rate e tamanho)
Sem isso, cache vira vazamento por design.
Prefira streaming a carregar tudo em memória
Para I/O:
- parse de arquivos grandes
- proxy de downloads
- processamento de logs
Use streams e pipeline para reduzir picos de RAM e evitar Buffer gigantes.
Limite payload e concorrência
Imponha limites:
- tamanho máximo de body
- número de requisições concorrentes para rotas caras
- tamanho de filas internas
Isso não só reduz RAM, como também melhora previsibilidade.
Ajuste flags do V8 apenas após correções
Flags como --max-old-space-size devem ser uma decisão informada:
- se o serviço realmente precisa de heap maior por workload legítimo;
- se há recursos na máquina/contêiner;
- se o GC não está virando gargalo.
Aumentar heap pode reduzir frequência de GC, mas aumentar duração de pausas em coletas maiores. Por isso, monitore latência.
Cyber Segurança: por que otimização de memória também é defesa
Problemas de memória frequentemente se tornam incidentes de segurança operacional:
- DoS por exaustão de memória: payloads grandes, requisições que geram buffers, compressão mal configurada, endpoints que acumulam trabalho.
- Ataques de amplificação de alocação: rotas que criam muitos objetos por entrada controlável pelo usuário.
- Fila infinita e retenção: eventos e jobs que nunca expiram.
Medidas defensivas alinhadas a performance:
- limites de tamanho e tempo (timeouts);
- validação de entrada e paginação;
- rate limiting;
- backpressure em streams;
- circuit breakers para dependências lentas.
Checklist rápido para manter o Node.js estável
- Monitore
rss,heapUsed,externale latência. - Desconfie de caches e
Map/Setglobais sem TTL/limite. - Evite recursão profunda e estruturas gigantes em memória.
- Use streaming para grandes volumes e aplique backpressure.
- Tire heap snapshots e encontre caminhos de retenção.
- Corrija a causa antes de aumentar
--max-old-space-size. - Imponha limites para reduzir risco de DoS por memória.
Conclusão
Otimizar memória em Node.js passa por entender onde a memória vive (stack vs heap vs external), como o Garbage Collection reage ao padrão de alocação e quais estruturas mantêm referências por tempo demais. Em produção, a abordagem mais eficaz combina monitoramento, reprodução controlada, snapshots de heap e correções cirúrgicas (TTL em caches, remoção de listeners, streaming e limites de concorrência).
Com esse conjunto de práticas, é possível reduzir custos, aumentar estabilidade e também melhorar a postura de segurança contra ataques de exaustão de recursos.