Ir para o conteúdo
Backend

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

Marcos Soares
13 minutos de leitura
Ilustração 3D de pilha de blocos de vidro translúcido transbordando luz verde representando memory leaks em JavaScript
Ouça este artigo
0:00Memory 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.

JavaScript
// 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.

JavaScript
// 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.

JavaScript
// 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).

JavaScript
// 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:

TypeScript
// 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.

TypeScript
// 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:

TypeScript
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.

JavaScript
// 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);
}
JavaScript
// 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.

TypeScript
// ERRADO em ambiente serverless ou por-tenant
export function conectarTenant(tenantId: string) {
  setInterval(() => sincronizar(tenantId), 60_000);
}
// Cada chamada empilha um timer eterno.
TypeScript
// 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.

JavaScript
// 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.

JavaScript
// 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

EstruturaImpede coleta do valor?Chave pode ser primitiva?Iterável?Quando usar
MapSimSimSimCache com teto e TTL explícitos
LRUCacheSim, até expulsarSimSimCache de produção com limite de memória previsível
WeakMapNão (chave fraca)Não, só objetoNãoMetadados atrelados ao ciclo de vida de um objeto
WeakSetNãoNão, só objetoNãoMarcar objetos já processados sem retê-los
WeakRefNãoN/AN/AReferência opcional com fallback obrigatório
FinalizationRegistryNãoN/AN/ALiberar 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

TypeScript
// 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:

  1. Suba o processo, gere carga por 2 minutos, tire o snapshot A (baseline aquecido).
  2. Mantenha a mesma carga por 10 minutos, tire o snapshot B.
  3. 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

TypeScript
// 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.

Bash
# ERRADO: adia o problema e piora o tempo de major GC
node --max-old-space-size=8192 servidor.js

Heap 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.

JavaScript
// 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.

JavaScript
// 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.

JavaScript
// 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

Marcos Soares

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.