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

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
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.
// ERRADO: cada requisição adiciona um listener no processo
import http from 'node:http';
import { EventEmitter } from 'node:events';
const barramentoDeMetricas = new EventEmitter();
const servidor = http.createServer((req, res) => {
// Esse listener nunca é removido. Ele captura `req` e `res`,
// que por sua vez seguram o socket e os headers.
barramentoDeMetricas.on('flush', () => {
console.log('requisição ainda viva:', req.url);
});
res.end('ok');
});
servidor.listen(3000);Depois de 10 mil requisições você tem 10 mil closures presas ao emitter, cada uma segurando um par req/res completo. O Node.js até avisa: MaxListenersExceededWarning: Possible EventEmitter memory leak detected. Esse warning existe exatamente por causa desse padrão e não deveria ser silenciado com setMaxListeners(0).
// CORRETO: escopo do listener casado com o escopo do request
import http from 'node:http';
import { EventEmitter } from 'node:events';
const barramentoDeMetricas = new EventEmitter();
const servidor = http.createServer((req, res) => {
const aoDarFlush = () => {
console.log('requisição ainda viva:', req.url);
};
barramentoDeMetricas.on('flush', aoDarFlush);
// 'close' dispara mesmo quando o cliente aborta a conexão,
// o que 'finish' não garante. Usar 'close' evita leak em aborts.
res.on('close', () => {
barramentoDeMetricas.off('flush', aoDarFlush);
});
res.end('ok');
});
servidor.listen(3000);No browser, AbortController resolve a limpeza de vários listeners de uma vez:
// hook React que remove todos os listeners com um único sinal
import { useEffect, useRef, useState } from 'react';
export function usePosicaoDeScroll() {
const [posicao, setPosicao] = useState(0);
const ticking = useRef(false);
useEffect(() => {
const controlador = new AbortController();
const aoRolar = () => {
if (ticking.current) return;
ticking.current = true;
requestAnimationFrame(() => {
setPosicao(window.scrollY);
ticking.current = false;
});
};
// passive: true evita bloquear a thread de composição no scroll
window.addEventListener('scroll', aoRolar, {
passive: true,
signal: controlador.signal,
});
window.addEventListener('resize', aoRolar, { signal: controlador.signal });
// um abort() derruba todos os listeners registrados com esse signal
return () => controlador.abort();
}, []);
return posicao;
}2. Caches sem política de expulsão
Map e objetos literais usados como cache são a segunda maior fonte de vazamento em serviços Node.js. A chave é sempre a mesma: a chave do cache tem cardinalidade ilimitada.
// ERRADO: cardinalidade infinita, cache cresce até o OOM
const cacheDeUsuarios = new Map<string, Usuario>();
export async function buscarUsuario(id: string): Promise<Usuario> {
const emCache = cacheDeUsuarios.get(id);
if (emCache) return emCache;
const usuario = await db.usuario.findUniqueOrThrow({ where: { id } });
cacheDeUsuarios.set(id, usuario); // nunca sai daqui
return usuario;
}Um cache sem limite de tamanho e sem TTL não é cache, é um vazamento com nome bonito. A correção é um LRU com teto explícito:
import { LRUCache } from 'lru-cache';
interface Usuario {
id: string;
nome: string;
email: string;
}
const cacheDeUsuarios = new LRUCache<string, Usuario>({
max: 5_000, // teto duro de entradas: previsibilidade de memória
ttl: 1000 * 60 * 5,
// updateAgeOnGet: false evita que uma chave quente fique viva
// para sempre, o que reintroduz o vazamento por outro caminho.
updateAgeOnGet: false,
});
export async function buscarUsuario(id: string): Promise<Usuario> {
const emCache = cacheDeUsuarios.get(id);
if (emCache) return emCache;
const usuario = await db.usuario.findUniqueOrThrow({ where: { id } });
cacheDeUsuarios.set(id, usuario);
return usuario;
}Calcule o teto: 5 mil usuários com objeto médio de 2 KB dá 10 MB. Esse número cabe no seu limite de container? Se cabe, o cache é seguro por construção. Se você não consegue estimar o tamanho médio da entrada, não coloque teto por contagem, coloque por peso usando a opção maxSize com sizeCalculation.
3. Closures que capturam mais do que precisam
O V8 cria um Context compartilhado por todas as closures declaradas no mesmo escopo. Se uma closure de vida longa está nesse escopo, ela mantém vivo todo o contexto, incluindo variáveis que ela nem usa.
// ERRADO: o timer de vida longa segura o buffer de 50 MB
function iniciarProcessamento(caminhoDoArquivo) {
const conteudoBruto = fs.readFileSync(caminhoDoArquivo); // 50 MB
const totalDeLinhas = conteudoBruto.toString().split('\n').length;
// Este callback só usa totalDeLinhas, mas compartilha o Context
// com conteudoBruto, que fica retido enquanto o intervalo existir.
setInterval(() => {
console.log('linhas processadas:', totalDeLinhas);
}, 30_000);
}// CORRETO: extraia o que precisa e feche o escopo grande
function extrairTotalDeLinhas(caminhoDoArquivo) {
const conteudoBruto = fs.readFileSync(caminhoDoArquivo);
return conteudoBruto.toString().split('\n').length;
// conteudoBruto sai de escopo aqui e vira coletável
}
function iniciarProcessamento(caminhoDoArquivo) {
const totalDeLinhas = extrairTotalDeLinhas(caminhoDoArquivo);
const intervalo = setInterval(() => {
console.log('linhas processadas:', totalDeLinhas);
}, 30_000);
// unref permite que o processo encerre mesmo com o timer ativo,
// e devolver o handle permite parar explicitamente.
intervalo.unref();
return () => clearInterval(intervalo);
}Essa retenção por Context compartilhado é notoriamente difícil de ver lendo código. Aparece no heap snapshot como uma edge chamada context apontando para o objeto grande. É um dos casos em que investigar sem console.log é a única saída viável.
4. Timers e streams sem clear
setInterval sem clearInterval é um vazamento garantido, porque o timer é uma GC root ativa. O mesmo vale para streams que nunca terminam.
// ERRADO em ambiente serverless ou por-tenant
export function conectarTenant(tenantId: string) {
setInterval(() => sincronizar(tenantId), 60_000);
}
// Cada chamada empilha um timer eterno.// CORRETO: registry com desligamento explícito
const timersPorTenant = new Map<string, NodeJS.Timeout>();
export function conectarTenant(tenantId: string) {
// idempotência: reconectar não deve criar um segundo timer
desconectarTenant(tenantId);
const timer = setInterval(() => sincronizar(tenantId), 60_000);
timersPorTenant.set(tenantId, timer);
}
export function desconectarTenant(tenantId: string) {
const timer = timersPorTenant.get(tenantId);
if (!timer) return;
clearInterval(timer);
timersPorTenant.delete(tenantId);
}
// Sem esse handler, um SIGTERM deixa timers pendurados e o processo
// demora até o kill -9 do orquestrador.
process.on('SIGTERM', () => {
for (const tenantId of timersPorTenant.keys()) desconectarTenant(tenantId);
});Serviços que mantêm conexão persistente sofrem disso de forma severa. Em uma arquitetura de WebSockets em escala com Redis Pub/Sub, cada socket derrubado sem limpeza de subscriber deixa handler, buffer de mensagens e estado de sala retidos.
5. Detached DOM no browser
Nó removido da árvore mas ainda referenciado por JavaScript. O navegador não pode liberar nem o nó nem seus filhos.
// ERRADO: guardar referência a nó que será removido
const cacheDeElementos = {};
function renderizarModal(dados) {
const modal = document.createElement('div');
modal.innerHTML = montarConteudo(dados);
document.body.appendChild(modal);
cacheDeElementos.ultimoModal = modal; // referência forte
modal.querySelector('.fechar').addEventListener('click', () => {
modal.remove(); // sai do DOM, mas segue vivo no heap
});
}Cada modal aberto e fechado deixa a subárvore inteira no heap. Em uma sessão de meia hora com 200 aberturas, isso vira dezenas de megabytes de nós órfãos.
// CORRETO: WeakRef quando você precisa mesmo guardar referência
const cacheDeElementos = {};
function renderizarModal(dados) {
const modal = document.createElement('div');
modal.innerHTML = montarConteudo(dados);
document.body.appendChild(modal);
// WeakRef não impede coleta. deref() retorna undefined
// depois que o GC recolheu o nó.
cacheDeElementos.ultimoModal = new WeakRef(modal);
modal.querySelector('.fechar').addEventListener('click', () => {
modal.remove();
delete cacheDeElementos.ultimoModal;
});
}
function focarUltimoModal() {
const modal = cacheDeElementos.ultimoModal?.deref();
if (!modal) return; // já foi coletado, comportamento esperado
modal.focus();
}Regra prática: se o dado é derivado do ciclo de vida de outro objeto e não deve prolongá-lo, use WeakMap/WeakSet. Se você precisa de acesso opcional a um objeto que pode morrer, use WeakRef. WeakRef exige lógica de fallback e por isso deve ser exceção, não padrão.
Escolhendo a estrutura de retenção certa
| Estrutura | Impede coleta do valor? | Chave pode ser primitiva? | Iterável? | Quando usar |
|---|---|---|---|---|
Map | Sim | Sim | Sim | Cache com teto e TTL explícitos |
LRUCache | Sim, até expulsar | Sim | Sim | Cache de produção com limite de memória previsível |
WeakMap | Não (chave fraca) | Não, só objeto | Não | Metadados atrelados ao ciclo de vida de um objeto |
WeakSet | Não | Não, só objeto | Não | Marcar objetos já processados sem retê-los |
WeakRef | Não | N/A | N/A | Referência opcional com fallback obrigatório |
FinalizationRegistry | Não | N/A | N/A | Liberar recurso externo (handle nativo, socket) |
FinalizationRegistry merece um aviso: a especificação não garante que o callback será chamado. Não use para lógica de negócio, apenas para liberar recursos onde o vazamento é aceitável em caso de não execução.
Diagnóstico: heap snapshot é o único caminho confiável
Ler código procurando vazamento funciona para o caso 1 e o caso 4. Para os outros três, você precisa de snapshot.
Em Node.js
// snapshot-endpoint.ts
import { writeHeapSnapshot } from 'node:v8';
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
export async function registrarDiagnostico(app: FastifyInstance) {
app.post('/_internal/heap-snapshot', async (request, reply) => {
// Endpoint de diagnóstico precisa de guarda: escrever snapshot
// pausa a thread principal por segundos em heaps de 1 GB+.
if (request.headers['x-diagnostic-token'] !== process.env.DIAGNOSTIC_TOKEN) {
return reply.code(404).send();
}
const caminho = `/tmp/heap-${Date.now()}-${randomUUID()}.heapsnapshot`;
writeHeapSnapshot(caminho);
return { caminho };
});
}O procedimento que funciona é a comparação de três snapshots:
- Suba o processo, gere carga por 2 minutos, tire o snapshot A (baseline aquecido).
- Mantenha a mesma carga por 10 minutos, tire o snapshot B.
- Mais 10 minutos, snapshot C.
Abra no Chrome DevTools (aba Memory, botão Load) e use o modo Comparison entre B e C. Ordene por Delta na coluna # New. O que cresce de forma consistente entre B→C e A→B é seu vazamento. Objetos que crescem só de A para B geralmente são cache aquecendo, não vazamento.
Na visão de um objeto suspeito, o painel Retainers mostra a cadeia até a GC root. É essa cadeia que responde a pergunta real: quem está segurando isso?
Ativando o coletor de allocation timeline em produção controlada
// allocation-profile.ts
import { Session } from 'node:inspector/promises';
import { writeFile } from 'node:fs/promises';
export async function coletarPerfilDeAlocacao(duracaoMs: number) {
const sessao = new Session();
sessao.connect();
await sessao.post('HeapProfiler.enable');
// trackAllocations: true registra a stack de cada alocação.
// Custa 10-30% de throughput, então rode em uma instância isolada.
await sessao.post('HeapProfiler.startSampling', {
samplingInterval: 32_768, // bytes entre amostras: menor = mais preciso e mais caro
});
await new Promise((resolve) => setTimeout(resolve, duracaoMs));
const { profile } = await sessao.post('HeapProfiler.stopSampling');
await writeFile(`/tmp/alloc-${Date.now()}.heapprofile`, JSON.stringify(profile));
sessao.disconnect();
}O .heapprofile abre no DevTools e mostra, por stack de chamada, quantos bytes foram alocados. É a ferramenta certa quando você sabe que vaza mas não sabe qual módulo é o culpado.
No browser
Chrome DevTools, aba Memory, opção Allocation instrumentation on timeline. Grave, execute o fluxo suspeito (abrir e fechar o modal 30 vezes, navegar entre rotas), pare a gravação. Barras azuis que permanecem azuis após a gravação são alocações que sobreviveram: candidatas a vazamento.
Para caçar detached DOM especificamente, digite Detached no filtro da aba Memory após um heap snapshot. O DevTools mostra Detached HTMLDivElement, Detached HTMLTableElement e afins, com os retainers.
O que NÃO fazer
Não aumente o limite de heap como correção.
# ERRADO: adia o problema e piora o tempo de major GC
node --max-old-space-size=8192 servidor.jsHeap maior significa major GC mais longo. Com 8 GB de old space, uma pausa de Mark-Compact pode passar de 500 ms, o que aparece direto no p99. Aumentar o limite só é legítimo quando você mediu o working set real e ele genuinamente não cabe no default.
Não force GC em código de produção.
// ERRADO
if (process.memoryUsage().heapUsed > LIMITE) {
global.gc(); // pausa síncrona, mata a latência
}global.gc() é uma ferramenta de medição, não de correção. Se você precisa dela para o serviço ficar de pé, o vazamento continua lá.
Não silencie o warning do EventEmitter.
// ERRADO
emitter.setMaxListeners(0); // "resolvido"O warning padrão dispara em 11 listeners. Se seu caso legítimo precisa de 50, defina 50 e documente por quê. Zero desliga o único detector automático de vazamento que o Node.js te dá de graça.
Não use delete esperando liberar memória.
// ERRADO: delete em objeto grande degrada o hidden class do V8
delete registro.payloadGigante;
// CORRETO: nulificar preserva o shape do objeto
registro.payloadGigante = null;delete rem

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


