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.
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);
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)