24 de nov. de 2025·8 min de leitura

Vazamento de memória no Node.js: encontre listeners, caches e timers fora de controle

Identifique um vazamento de memória no Node.js localizando listeners, caches e timers fora de controle e comprove a correção com snapshots de heap e testes repetíveis.

Vazamento de memória no Node.js: encontre listeners, caches e timers fora de controle

Como é um vazamento de memória em um app Node.js

Um vazamento de memória em Node.js acontece quando seu app continua segurando memória que já não precisa. O detalhe importante é que o uso de memória sobe ao longo do tempo e nunca volta totalmente ao normal, mesmo depois do trabalho que causou o pico terminar.

Em produção, costuma aparecer como uma subida lenta: tudo parece OK por minutos ou horas, depois as respostas ficam mais lentas, o processo começa a fazer GC com mais frequência e, eventualmente, o app reinicia ou cai com erro de falta de memória. Se você observar métricas básicas, pode ver RSS e uso de heap subindo ao longo de deploys, ciclos de tráfego ou jobs em background.

Sinais comuns incluem:

  • A memória sobe após cada requisição ou execução de job
  • Pausas de GC ficam maiores e mais frequentes
  • Reinícios tornam-se previsíveis (por exemplo, toda noite após um lote)
  • Containers batem no limite de memória mesmo com tráfego normal
  • O app fica mais lento sem um pico óbvio de CPU

Nem tudo que sobe é um leak. Um crescimento pode ser normal: um cache aquecendo, uma etapa de compilação única ou um estouro de tráfego que se estabiliza quando a carga cai. Além disso, algumas “vazamentos” são na verdade caches intencionais que estão grandes demais ou sem limites. A diferença é se os níveis de memória se estabilizam. Um app saudável pode subir e depois pairar em torno de uma linha de base. Um app com vazamento continua definindo novas linhas de base.

Um exemplo simples: um protótipo adiciona um event listener a cada requisição, mas nunca o remove. Cada requisição “termina”, mas o listener mantém referência a dados daquela requisição. A memória sobe um passo pequeno de cada vez até virar um problema grande.

Para consertar vazamentos sem chutar no escuro, você precisa de duas coisas: uma forma repetível de disparar o crescimento e uma maneira de provar que a correção funcionou. Essa prova geralmente é a memória se estabilizando e snapshots de heap mostrando que os objetos que cresciam não estão mais crescendo.

Triagem rápida: confirme que você está perseguindo o problema certo

Antes de caçar um vazamento em Node.js, certifique-se de que o app realmente está vazando (a memória sobe e fica alta), e não apenas lidando com um pico temporário (memória sobe e depois cai após GC).

Comece observando alguns números juntos. Uma métrica isolada pode enganar, especialmente em código de protótipo onde “soluções rápidas” escondem a causa real.

  • Process memory (RSS): memória total que o SO diz que o processo usa.
  • Heap used: objetos JavaScript gerenciados pelo V8.
  • Event loop lag: se subir, o app está preso fazendo trabalho ou o GC está thrashing.
  • Latência de requisição: vazamentos frequentemente aparecem como respostas mais lentas com o tempo.
  • Taxa de erros / timeouts: um “vazamento” às vezes é tempestade de retries ou jobs travados.

A seguir, diferencie “crescimento de heap” e crescimento de memória nativa em alto nível. Se heap used continua subindo durante vários minutos de carga estável, é provável que você esteja retendo objetos JS (listeners, caches, closures, arrays, Maps). Se RSS cresce mas heap used fica relativamente estável, suspeite de memória fora do heap JS: Buffers grandes, streams não fechados, add-ons nativos ou até uma lib de logging/metrics acumulando dados.

Vazamentos que vêm de protótipos geralmente vêm dos mesmos hábitos: singletons globais que acumulam estado, caches sem limite de tamanho, event listeners adicionados por requisição e timers iniciados sem caminho de parada. Isso aparece muito em apps gerados ou muito alterados por ferramentas de IA, onde o código “funciona uma vez” mas falta limpeza.

Defina um objetivo simples antes de mexer no código: reproduza o crescimento em minutos, não dias. Escolha uma rota ou job que dispare o problema, aplique carga estável e defina “sucesso” como: depois que o teste começa, a memória aumenta num padrão repetível (por exemplo, +20 MB a cada 2 minutos). Com isso em mãos, você pode provar a correção depois.

Se você herdou um protótipo bagunçado e não consegue nem obter medições estáveis, esse é exatamente o ponto em que uma auditoria rápida (como a que FixMyMess faz) pode identificar os maiores riscos de retenção antes de reescrever metade do app.

Torne o vazamento reproduzível antes de tocar no código

Um vazamento em Node.js é difícil de consertar se só aparece “às vezes”. Antes de mudar qualquer coisa, transforme o problema em uma ação repetível que faça a memória subir sob comando.

Comece escolhendo um gatilho que corresponda ao uso real. Bons candidatos são uma única rota HTTP, um tick de job em background, um ciclo de conexão/disconexão WebSocket ou uma tarefa de importação. Escolha a menor ação que ainda cause a subida.

Então repita esse gatilho em loop fechado. Você pode fazer manualmente (recarregar a mesma página 50 vezes), mas um script pequeno é melhor porque elimina variação humana. O ponto não é teste de carga; é consistência.

Mantenha variáveis estáveis enquanto reproduz:

  • Use a mesma conta de usuário e permissões a cada execução
  • Use o mesmo tamanho de entrada (mesmo payload, mesmo número de registros)
  • Use o mesmo ambiente (local ou staging, não misturado)
  • Reinicie o app entre experimentos para começar de uma linha de base limpa
  • Desative tráfego não relacionado para que ruído de background não esconda o padrão

Agora defina uma métrica clara de passa/falha. Uma simples é: depois do loop, forçar uma coleta de lixo durante o teste; o heap deve voltar próximo da linha de base. Se continuar subindo a cada execução, você tem um repro confiável.

Exemplo concreto: digamos que a memória cresce quando usuários abrem uma tela de “atualizações ao vivo”. Faça um loop que conecta ao WebSocket, espera 3 segundos, desconecta e repete 200 vezes. Se o heap cresce a cada ciclo de conexão, você restringiu o vazamento a listeners, timers ou caches por conexão.

Aqui também é onde apps de protótipo costumam quebrar. Se seu código foi gerado por ferramentas como Replit, v0 ou Cursor e é difícil tornar o vazamento repetível, FixMyMess pode rodar uma auditoria rápida para isolar a ação que dispara o crescimento antes de você gastar horas chutando.

Snapshots de heap: a ferramenta que torna vazamentos visíveis

Um snapshot de heap é uma foto dos objetos que seu processo Node.js está segurando na memória num dado momento. Mostra quantos objetos existem, qual o tamanho e o que os mantém vivos. Quando você está lidando com um vazamento em Node.js, essa é muitas vezes a forma mais rápida de parar de adivinhar.

O que ele pode dizer: quais tipos de objeto continuam crescendo (arrays, Maps, strings, closures) e os caminhos que mantêm esses objetos alcançáveis. O que ele não pode dizer: a linha exata de código que criou um objeto, ou se um pico de curto prazo é por si só “ruim”. Você ainda precisa de um teste reproduzível e um pouco de trabalho de detetive.

Uma ideia chave em snapshots são os caminhos de retenção. Um objeto não é coletado se algo ainda o referencia. Os caminhos de retenção são a cadeia de referências que mantém um objeto vivo, por exemplo: um singleton global mantém um Map, o Map mantém payloads de requisição e esses payloads incluem strings grandes. O “vazamento” geralmente não é o objeto que você vê crescendo, mas o retentor que deveria ter sido limpo.

Planeje tirar três snapshots para comparar o que cresce ao longo do tempo:

  • Baseline: logo após o startup, antes de qualquer carga.
  • Snapshot 2: após uma quantidade conhecida de carga (por exemplo, 200 requisições).
  • Snapshot 3: após mais da mesma carga (por exemplo, 600 requisições).

Se os mesmos grupos de objetos aumentam de snapshot 2 para 3, você tem um sinal forte. Se a memória sobe mas a contagem de objetos fica estável, pode ser buffers, add-ons nativos ou caching normal.

Privacidade importa. Snapshots de heap podem incluir dados de usuários, tokens, cookies, payloads e até segredos expostos que protótipos às vezes logam ou armazenam em memória. Trate snapshots como dados de produção: salve com cuidado, compartilhe com parcimônia e delete quando terminar.

Passo a passo: capturar e comparar snapshots para achar o que cresce

Get a free leak audit
Envie seu app Node e identificaremos o que mantém a memória crescendo.

Quando você suspeita de um vazamento em Node.js, snapshots de heap são a forma mais rápida de parar de adivinhar. O truque é tirar snapshots em torno de uma ação repetida para ver o que cresce a cada vez.

1) Capture três snapshots em torno da mesma ação

Inicie seu app de um jeito que permita tirar snapshots de heap (por exemplo via debugger ou inspector). Depois repita uma ação de usuário que você acredita que dispare o vazamento (uma requisição, um carregamento de página, execução de job).

Use esse ritmo simples:

  • Snapshot A: faça uma baseline logo depois que o app “assentar”
  • Execute a mesma ação N vezes (comece com 20–50)
  • Snapshot B: tire um segundo snapshot imediatamente depois
  • Execute a mesma ação N vezes de novo
  • Snapshot C: tire um terceiro snapshot

Se Snapshot B for maior que A, e C maior que B numa quantidade similar, esse aumento constante por iteração é um sinal forte de vazamento.

2) Compare snapshots e siga o caminho de retenção

Abra a visão de comparação entre A e B (depois entre B e C). Foque em tipos de objeto que aumentam, não em picos pontuais.

Procure por:

  • Nomes de constructor que continuam subindo (por exemplo: Array, Map, Listener, Timeout, Buffer)
  • Coleções que crescem (entradas de Map, itens de Set, objetos em cache)
  • Objetos “desconectados” ou parecendo “unreachable” mas ainda retidos
  • O caminho de retenção (o que está segurando o objeto em memória)

O caminho de retenção é a parte que vale dinheiro. Muitas vezes aponta para um singleton global, um cache em nível de módulo, um event emitter ou uma lista de timers.

3) Anote o que você vê antes de mudar o código

Enquanto inspeciona, faça notas rápidas para não perder o fio:

  • Os 2–3 nomes de constructor que mais crescem
  • A raiz de retenção (global, export de módulo, closure do handler de requisição)
  • Qualquer indicação de arquivo ou módulo mostrada no snapshot
  • Taxa de crescimento aproximada (por exemplo: +500 objetos a cada 50 requisições)

Essa lista curta torna a correção focada. É também a mesma evidência que times como FixMyMess usam numa auditoria gratuita para apontar se o vazamento vem de listeners, caches ou timers antes de tocar no código.

Event listeners fora de controle: o vazamento mais comum em protótipos

Um clássico vazamento em Node.js em código de protótipo é simples: um listener é adicionado repetidamente, mas nunca removido. A memória cresce devagar, depois de repente o processo começa a pausar, dar timeout ou travar.

Isso frequentemente acontece quando uma função de “setup” roda a cada requisição, reconexão ou job, e faz algo como emitter.on(...) sem checar se já está inscrita. Cada novo listener pode manter dados extras vivos, especialmente quando o handler fecha sobre objetos de requisição, dados de usuário ou Buffers grandes.

Locais comuns onde isso aparece:

  • Instâncias de EventEmitter usadas como barramentos globais
  • Conexões WebSocket que reconectam e se reinscrevem
  • Streams HTTP onde handlers de data e error se acumulam
  • Clients de banco que adicionam listeners em cada query
  • Eventos de process como uncaughtException ou SIGTERM registrados repetidamente

Snapshots de heap podem revelar esse padrão. Procure um número crescente de funções em arrays de listeners (geralmente em um emitter), ou muitas closures semelhantes que referenciam as mesmas variáveis externas. Uma pista forte é ver objetos retidos que parecem dados de requisição/resposta presos na closure de uma função listener.

Um exemplo concreto: uma rota Express chama subscribeToUpdates(userId) em cada requisição, e essa função faz ws.on('message', ...). Se nunca cancelar a inscrição quando a requisição termina (ou quando o usuário desconecta), o WebSocket mantém referências a handlers antigos e seus dados capturados.

As correções geralmente são simples mas eficazes:

  • Use once para eventos que devem disparar somente uma vez
  • Chame off/removeListener durante a limpeza (on disconnect, request end, job finish)
  • Evite inscrições por requisição em emitters globais; roteie eventos por um objeto com escopo
  • Armazene a função handler para poder removê-la usando a mesma referência depois
  • Adicione guardrails: registre listenerCount e trate avisos como bugs reais

Se você herdou código gerado por IA com esses padrões, FixMyMess frequentemente começa mapeando o ciclo de vida dos listeners e removendo armadilhas de “inscrição a cada chamada” antes de tudo.

Caches que só crescem: Map, memoização e singletons globais

Muitos “vazamentos” em protótipos não são bugs misteriosos. São caches que nunca liberam. Numa busca por vazamento em Node.js, esse é um dos primeiros lugares a olhar porque costuma parecer “o app funciona” até que o tráfego ou o tempo torne o cache enorme.

O padrão clássico é um Map ou objeto simples usado como lookup rápido, mas sem limite de tamanho e sem expiração. Se a chave é construída a partir de input do usuário (termos de busca, URLs, headers, IDs de usuário, prompts), o número de chaves únicas pode crescer para sempre.

O que procurar

Comece encontrando qualquer coisa que armazene dados entre requisições: variáveis em nível de módulo, singletons ou arquivos “helper” que exportam um cache.

Alguns culpados comuns:

  • Um mapa de deduplicação de requisições (inFlightRequests.get(key)) que nunca deleta em caminhos de erro
  • Memoização em torno de funções caras, indexada por input cru
  • Um mapa global de “últimas respostas” para debugging ou analytics
  • Caches que guardam objetos de resposta inteiros, linhas do DB ou Buffers
  • Dados tipo sessão mantidos em memória ao invés de um store real

Um cenário pequeno que vaza rápido: você cacheia resultados de GET /search?q=... com chave sendo a query completa. Uma semana depois, você tem centenas de milhares de queries únicas, e cada valor inclui um payload JSON grande. Snapshots de heap frequentemente mostram um grande Map (ou Object) retendo arrays, strings e objetos aninhados.

Padrões de cache mais seguros

Consertar geralmente significa fazer o cache se comportar como cache, não como arquivo:

  • Adicione um tamanho máximo rígido (evict LRU ou entradas mais antigas)
  • Adicione TTL e limpe num intervalo que possa ser parado
  • Normalize chaves (lowercase, trim, ordenar params) para reduzir explosão de chaves
  • Armazene IDs ou resumos pequenos, não objetos inteiros
  • Sempre delete entradas em falhas e em caminhos de timeout

Se você herdou um protótipo gerado por IA, esses caches frequentemente estão espalhados por arquivos utilitários e singletons. FixMyMess costuma achar 2–3 Maps crescendo no mesmo código, cada um segurando mais do que precisa.

Intervals e loops em background que nunca param

Prove the fix
Usamos snapshots de heap para confirmar que os objetos “crescentes” pararam de crescer.

Timers são uma forma fácil de criar um vazamento em Node.js, especialmente em apps que começaram como protótipos. O erro clássico é criar um setInterval() (ou setTimeout() encadeado) dentro de um handler de requisição e nunca limpá-lo. Cada requisição adiciona outro loop em background que mantém referências vivas.

Isso acontece com features “rápidas”: polling de uma API externa, retry de jobs, checagem de fila ou refresh de cache. Quando esse código fica dentro de uma rota ou setup por usuário, o timer fecha sobre dados da requisição (user id, token, payload) e essa closure fica na memória enquanto o timer existir.

Um exemplo realista: uma rota Express /start-sync seta um intervalo para checar progresso a cada 2 segundos. Se o usuário atualizar a página ou chamar duas vezes, agora existem dois intervals para o mesmo usuário. Multiplique isso por tráfego real e a memória sobe constantemente.

Snapshots de heap podem dar dicas fortes. Frequentemente você verá contagens crescentes de objetos relacionados a timers, mais closures retendo dados de requisição. Se a comparação de snapshots mostrar mais “listeners” e objetos Timeout a cada execução, a lista de timers está crescendo.

Padrões de correção que funcionam:

  • Crie timers agendados uma vez no startup, não dentro de rotas
  • Armazene IDs de timer e sempre chame clearInterval() ou clearTimeout() quando o job terminar
  • Vincule a vida do timer à conexão: cancele em disconnect, logout ou quando um WebSocket fecha
  • Proteja contra duplicados (por exemplo, um interval por usuário ou por workspace)
  • Prefira um único loop de worker que puxa jobs de uma fila ao invés de um timer por requisição

Após a mudança, rerun o mesmo load e tire novos snapshots de heap. Se a correção for real, objetos relacionados a timers param de crescer e a memória começa a se estabilizar.

Se seu app foi gerado por ferramentas como Lovable, Bolt ou Replit e timers estão espalhados por rotas, FixMyMess pode fazer uma auditoria rápida e apontar exatamente onde os loops são criados e por que nunca são parados.

Comprove a correção: rerun o teste e confirme que a memória estabiliza

Uma correção real muda o que o app mantém em memória. Um paliativo só muda o que você percebe. Reiniciar o servidor, aumentar memória do container ou forçar GC pode deixar os gráficos melhores por um tempo, mas o mesmo vazamento ainda existe.

Trate o passo de prova como um experimento de laboratório. Use os mesmos passos de reprodução, o mesmo tamanho de dados e as mesmas configurações de runtime que você usou quando viu o vazamento pela primeira vez.

Capture um novo conjunto de snapshots de heap: um no início limpo, um depois que o vazamento teve tempo de crescer e (se seu teste incluir cooldown) um depois que a carga para. Então compare com os snapshots “antes”.

Você terminou quando duas coisas forem verdadeiras:

  • Os tipos de objeto que costumavam crescer (por exemplo, arrays de listeners, entradas de Map, respostas em cache, closures de timers) param de aumentar entre snapshots.
  • Depois que a carga termina, o heap sobe e desce, então se estabiliza perto de uma faixa constante em vez de subir a cada execução.

Um exemplo simples: você removeu um setInterval que era criado por requisição. No próximo teste, o snapshot dois não deve mais mostrar milhares de callbacks idênticos de interval retendo dados de requisição, e o snapshot três não deve ser significativamente maior que o dois.

Se o vazamento parece “corrigido” mas o heap ainda sobe, verifique se você não apenas moveu o crescimento para outro lugar. Máscaras comuns incluem adicionar um cache LRU mas nunca definir o tamanho, ou remover listeners num caminho de sucesso mas não nas rotas de erro.

Para segurança a longo prazo, adicione um pequeno teste de regressão antes do release. Mantenha curto e simples, só o suficiente para pegar a volta do mesmo padrão:

  • Rode uma carga pequena por 2–5 minutos em um build de staging.
  • Registre pico de RSS/heap e falhe se ultrapassar um limite sensato.
  • Opcionalmente salve um snapshot e compare contagens de objetos retidos.

Se você herdou um app de protótipo gerado por IA e o mesmo vazamento continua reaparecendo depois de patches rápidos, FixMyMess normalmente roda esse ciclo de rerun-e-comparar após reparos para que a mudança seja verificada, não apenas um palpite.

Erros comuns que fazem perder horas

Get deployment-ready
Refatoramos código bagunçado para que deploys parem de reiniciar por erros de memória.

Um snapshot só não é prova. Uma única visão de heap pode mostrar muitos objetos, mas não diz o que está crescendo. Você precisa de pelo menos dois snapshots tirados no mesmo ponto do seu teste para comparar e ver quais constructors e retentores seguem aumentando.

Ruído é outro tempo perdido. Se você ataca o app com padrões mistos de tráfego (login, uploads, cron, páginas aleatórias) verá crescimento difícil de explicar. Mantenha um loop repetível que dispare o vazamento suspeito e mude só uma coisa por vez.

Também é fácil culpar a coleta de lixo. GC em Node.js pode parecer “preguiçoso” sob carga, mas se a memória continua subindo e nunca cai, algo ainda está fortemente referenciado. Os culpados usuais são Maps globais, arrays em módulos, closures capturando objetos grandes e event listeners adicionados a cada requisição sem remoção. Ao caçar um vazamento, foque no que está segurando referências, não nas configurações de GC.

Outra armadilha é consertar o sintoma em vez da causa. Limpar um cache “quando fica grande” pode esconder o problema por uma semana e então ele volta em produção.

O que fazer em vez disso

Busque correções que tornem o crescimento impossível:

  • Adicione limites de tamanho e políticas de eviction (TTL ou LRU) a caches em memória.
  • Garanta que listeners sejam registrados uma vez ou removidos na limpeza.
  • Pare timers e intervals quando um job termina ou um socket fecha.
  • Evite armazenar objetos de requisição, sessões ou respostas grandes em globais.

Exemplo: um protótipo adiciona setInterval por sessão de usuário para “atualizar dados”, mas nunca limpa no logout. O heap parece aleatório até você rodar um loop login/logout e comparar snapshots. Então um único callback de timer retido aparece como raiz.

Se você herdou um app Node gerado por IA e o mesmo vazamento reaparece após patches rápidos, FixMyMess normalmente começa com uma auditoria curta para apontar o caminho exato de retenção e aplicar uma limpeza real e limites para que a correção seja permanente.

Checklist rápido e próximos passos

Um vazamento de memória em Node.js é fácil de discutir e difícil de provar. Use este checklist rápido para manter o trabalho focado e garantir que você pode mostrar que o vazamento acabou, não apenas que “ficou melhor na sua máquina”.

Checklist rápido

  • Você consegue reproduzir o crescimento em menos de 10 minutos com um teste repetível (mesmas endpoints, mesmos payloads, mesma concorrência)?
  • Você tirou pelo menos 3 snapshots de heap (baseline, meio do teste, perto da falha) e comparou o que cresce entre eles?
  • Você inspecionou especificamente os suspeitos usuais: event listeners, caches em memória (Map, arrays, memoização) e timers/intervals?
  • Após mudanças de código, você rerodou o mesmo teste e confirmou que a memória se estabiliza (e os ciclos de GC não continuam subindo)?
  • Você verificou sinais auxiliares que apontam para a causa, como contagem de listeners, handles abertos e contagens de chaves em Maps que crescem?

Se algum item falhar, pause e corrija seu processo primeiro. A maior perda de tempo vem de mudar código antes de reproduzir o problema de forma confiável, ou de tirar um único snapshot e chutar.

Próximos passos

Depois de provar estabilidade, evite que o problema volte:

  • Adicione um soak test simples na rotina de release (10–20 minutos geralmente pega regressões).
  • Coloque guardrails em código propenso a crescimento: limite caches, remova listeners na limpeza e pare intervals quando o trabalho termina.
  • Documente “ownership” de loops em background e singletons para que não se multipliquem conforme o app evolui.

Se seu app começou como um protótipo gerado por IA, vazamentos frequentemente vêm de estado global emaranhado, listeners duplicados ou jobs de background adicionados durante experimentos e nunca removidos. Nesses casos, uma auditoria focada seguida de um refactor direcionado costuma ser mais rápida do que patches pontuais. FixMyMess pode rodar uma auditoria gratuita de código, apontar o que está crescendo e ajudar a transformar o protótipo em código pronto para produção com correções verificadas.