search

Otimizando Memória no Node.js: Heap, Stack e Garbage Collection

Otimizando Memória no Node.js: Heap, Stack e Garbage Collection

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

  1. Recursão profunda sem condição de parada adequada.
  2. Recursão com dados grandes (por exemplo, percorrendo árvore/JSON muito profundo).
  3. 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/res e 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

Garbage Collection

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 highWaterMark e à 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:

  1. Inicie o processo.
  2. Aplique carga por um período.
  3. Tire snapshot.
  4. Repita (após mais carga).
  5. 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

  1. Monitore rss, heapUsed, external e latência.
  2. Desconfie de caches e Map/Set globais sem TTL/limite.
  3. Evite recursão profunda e estruturas gigantes em memória.
  4. Use streaming para grandes volumes e aplique backpressure.
  5. Tire heap snapshots e encontre caminhos de retenção.
  6. Corrija a causa antes de aumentar --max-old-space-size.
  7. 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.

Tags: Cursos
Compartilhar este artigo: