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.