DEV Community

Marcos Soares
Marcos Soares

Posted on Originally published at vivodecodigo.com.br

Memory Leaks em JavaScript: Diagnóstico, Causas Reais e Correção

Um processo Node.js que reinicia sozinho a cada 40 minutos raramente está com bug de lógica. Está com vazamento de memória.

O sintoma clássico: o container roda liso por meia hora, o heap_size_used sobe em degraus, o garbage collector começa a rodar com mais frequência (major GC a cada poucos segundos em vez de a cada poucos minutos), a latência p99 dobra e o orquestrador mata o pod com OOMKilled. Reiniciar resolve. Por 40 minutos.

Este post é sobre encontrar a causa, não sobre aumentar --max-old-space-size.

Como o V8 decide o que é lixo

Existe um mito persistente de que o garbage collector do JavaScript usa contagem de referências. Não usa, e essa confusão faz gente escrever código defensivo inútil.

O V8 usa mark-and-sweep com alcançabilidade a partir de um conjunto de raízes (GC roots): o objeto global, a pilha de execução atual, closures ativas, e no browser, a árvore do DOM viva. Tudo que não é alcançável a partir dessas raízes vira lixo, independentemente de quantas referências circulares exista entre os objetos.

// Isto NÃO vaza. Ciclo é irrelevante para mark-and-sweep.
function criarCicloIsolado() {
  const pedido = { id: 1 };
  const cliente = { nome: 'Ana' };

  pedido.cliente = cliente;
  cliente.ultimoPedido = pedido; // referência circular

  return null; // ninguém mais alcança pedido nem cliente
}

criarCicloIsolado();
// Ambos são coletados no próximo major GC.
Enter fullscreen mode Exit fullscreen mode

O heap do V8 é dividido em gerações. Objetos novos nascem na new space (Scavenger, coleta rápida e frequente, tipicamente 1-16 MB). Objetos que sobrevivem a duas coletas são promovidos para a old space, varrida pelo Mark-Compact, que é caro. Um vazamento é, quase sempre, objetos sendo promovidos para old space e nunca liberados de lá.

Isso importa na prática: se seu vazamento envolve objetos grandes e de vida curta, você não tem vazamento, tem pressão de alocação (aumenta o custo de GC mas o heap estabiliza). Se o heapUsed após um major GC forçado continua subindo, aí sim é vazamento.

// medir-heap.js — rode com: node --expose-gc medir-heap.js
// --expose-gc é a única forma confiável de medir heap "limpo" sem
// confundir lixo pendente com vazamento real.

function heapDepoisDoGC() {
  global.gc(); // força major GC síncrono
  const { heapUsed, external, arrayBuffers } = process.memoryUsage();
  return {
    heapMB: (heapUsed / 1024 / 1024).toFixed(2),
    externalMB: (external / 1024 / 1024).toFixed(2),
    arrayBuffersMB: (arrayBuffers / 1024 / 1024).toFixed(2),
  };
}

setInterval(() => {
  console.log(new Date().toISOString(), heapDepoisDoGC());
}, 10_000);
Enter fullscreen mode Exit fullscreen mode

Se heapMB sobe monotonicamente ao longo de 20 amostras sob carga constante, você tem vazamento no heap JavaScript. Se heapMB fica estável mas o RSS do processo sobe, o vazamento está fora do heap do V8: Buffers, addons nativos, ou fragmentação do alocador.

Essa distinção entre linguagem e runtime é a mesma que aparece quando se discute o modelo de execução do JavaScript: a especificação da linguagem não diz nada sobre GC gerational nem sobre libuv. Tudo isso é V8 e Node.js.

As cinco causas que respondem por quase todo vazamento real

1. Listeners que nunca são removidos

O caso mais comum em Node.js e o mais comum em SPAs. Cada addEventListener ou .on() cria uma referência forte do emissor para o handler, e do handler para tudo que ele captura no escopo.


Este é um trecho. O tutorial completo, com todos os exemplos, os testes e as limitações, está em https://www.vivodecodigo.com.br/backend/memory-leaks-javascript-diagnostico-heap-snapshot-producao. Se você já passou por esse problema de outro jeito, conta nos comentários: a discussão aqui ajuda a melhorar o artigo.

Top comments (0)