Dicas para depurar problemas de memória em Node.js

1. Entendendo o Gerenciamento de Memória no Node.js

O Node.js utiliza o motor V8 do Google para executar JavaScript, que implementa um garbage collector (GC) sofisticado. O gerenciamento de memória no V8 divide-se em duas áreas principais: heap (onde objetos e closures são alocados) e stack (onde variáveis locais e chamadas de função residem). O GC opera em gerações: objetos jovens (young generation) são coletados rapidamente, enquanto objetos que sobrevivem a múltiplas coleções são promovidos para a old generation.

Referências fortes mantêm objetos vivos, enquanto referências fracas (WeakRef) permitem que o GC colete objetos se não houver outras referências fortes. Entender esse ciclo é fundamental para depurar vazamentos.

// Exemplo: monitorando uso de memória
const memUsage = process.memoryUsage();
console.log('RSS:', memUsage.rss);
console.log('Heap Total:', memUsage.heapTotal);
console.log('Heap Used:', memUsage.heapUsed);
console.log('External:', memUsage.external);

2. Identificando Sintomas de Vazamento de Memória

O primeiro passo para depurar problemas de memória é reconhecer os sintomas. Use process.memoryUsage() para monitorar o consumo ao longo do tempo. Sinais comuns incluem crescimento contínuo do heap mesmo após a coleta de lixo, degradação progressiva de performance e aumento do RSS (Resident Set Size).

O Node.js oferece flags nativas úteis:

# Ativar rastreamento do GC
node --trace-gc app.js

# Limitar o tamanho máximo do heap (em MB)
node --max-old-space-size=512 app.js

Um padrão suspeito é quando o heap usado nunca retorna ao nível anterior após picos de alocação:

setInterval(() => {
  const usage = process.memoryUsage();
  console.log(`Heap usado: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`);
}, 2000);

3. Capturando e Analisando Heap Dumps

Heap dumps são instantâneos da memória que permitem inspecionar objetos retidos. O módulo heapdump gera snapshots sob demanda:

const heapdump = require('heapdump');
const fs = require('fs');

// Gerar snapshot manualmente
heapdump.writeSnapshot(`./heap-${Date.now()}.heapsnapshot`);

// Ou sob condição
setInterval(() => {
  if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) {
    heapdump.writeSnapshot(`./heap-critico-${Date.now()}.heapsnapshot`);
  }
}, 30000);

Para análise, carregue o arquivo .heapsnapshot no Chrome DevTools (aba Memory). Compare dois snapshots — um antes e outro depois de uma operação — para identificar objetos que não foram liberados.

Ferramentas como clinic.js automatizam esse processo:

npx clinic heapprofiler -- node app.js

4. Detectando Vazamentos com Ferramentas de Profiling

O modo --inspect permite conectar o Chrome DevTools diretamente ao processo Node.js:

node --inspect app.js

No DevTools, use a aba Memory para:
- Gravar alocações em tempo real (Allocation sampling)
- Identificar closures que retêm escopos
- Detectar listeners de eventos não removidos

Flamegraphs com a ferramenta 0x revelam onde a CPU está sendo gasta, mas também podem indicar vazamentos indiretos:

npx 0x app.js

Padrões comuns de vazamento incluem:

// Vazamento por closure
function criarVazamento() {
  const dadosPesados = new Array(1000000).fill('x');
  return function() {
    console.log(dadosPesados.length); // Closure retém dadosPesados
  };
}

// Vazamento por listener não removido
const EventEmitter = require('events');
const emissor = new EventEmitter();

function registrarListener() {
  emissor.on('evento', () => {
    // Este listener nunca é removido
    console.log('evento recebido');
  });
}

5. Estratégias para Corrigir Vazamentos Comuns

Para timers e intervalos, sempre armazene e limpe as referências:

const intervalId = setInterval(() => {
  // lógica
}, 1000);

// Quando não for mais necessário
clearInterval(intervalId);

Para EventEmitters, remova listeners explicitamente:

function handler() {
  console.log('evento tratado');
}

emissor.on('evento', handler);

// Remover quando não precisar mais
emissor.off('evento', handler);

Caches e mapas devem ser controlados com limites de tamanho:

const cache = new Map();
const MAX_CACHE = 100;

function adicionarAoCache(chave, valor) {
  if (cache.size >= MAX_CACHE) {
    const primeiraChave = cache.keys().next().value;
    cache.delete(primeiraChave);
  }
  cache.set(chave, valor);
}

Para objetos temporários, use WeakRef e FinalizationRegistry:

const registry = new FinalizationRegistry((valor) => {
  console.log(`Objeto ${valor} foi coletado`);
});

let objeto = { dados: 'temporario' };
const ref = new WeakRef(objeto);
registry.register(objeto, 'meu-objeto');

// Quando objeto for sobrescrito, o GC pode coletá-lo
objeto = null;

6. Otimizando o Garbage Collector

Ajustes no GC podem reduzir pausas e melhorar performance:

# Coleta global mais agressiva
node --gc-global app.js

# Desabilitar sweeping concorrente para debug
node --noconcurrent_sweeping app.js

# Otimizar para tamanho (reduz fragmentação)
node --optimize_for_size app.js

# Ajustar tamanho do semi-space (espaço jovem)
node --max_semi_space_size=64 app.js

Evite padrões que forçam GC excessivo:

// Ruim: alocação em loop
for (let i = 0; i < 100000; i++) {
  const temp = new Array(1000);
}

// Bom: reutilizar objeto
const buffer = new Array(1000);
for (let i = 0; i < 100000; i++) {
  buffer.fill(0);
  // usar buffer
}

7. Boas Práticas para Prevenir Problemas Futuros

Implemente limites de memória no código:

const MAX_HEAP = 400 * 1024 * 1024; // 400 MB

function verificarMemoria() {
  const usado = process.memoryUsage().heapUsed;
  if (usado > MAX_HEAP) {
    console.error('Memória excedida. Reiniciando...');
    process.exit(1);
  }
}

setInterval(verificarMemoria, 10000);

Monitore continuamente com APM como New Relic ou Datadog:

# Exemplo com clinic.js para testes de estresse
npx clinic doctor -- node app.js

Revise o código focando em:
- Escopo de variáveis (evite globais)
- Ciclo de vida de objetos (liberação adequada)
- Uso de streams em vez de buffers grandes
- Implementação de backpressure em operações assíncronas

Referências