Back

Debug de Memory Leaks no Node.js: Guia Completo de Troubleshooting com Exemplos Reais

No início, tudo parece normal. Seu servidor Node.js roda tranquilo por horas, dias. Aí um dia você olha o dashboard de monitoramento e percebe que o gráfico de memória está subindo... devagar, mas subindo. Reiniciar o servidor vira rotina semanal. Depois diária. De repente você tá escrevendo um cron job pra reiniciar automaticamente a cada poucas horas. Algo está muito errado.

Memory leak no Node.js é um daqueles bugs que dá dor de cabeça. Diferente de um erro de sintaxe que explode na sua cara, memory leak fica quietinho, se escondendo. Aparece só quando tem carga pesada, geralmente em produção onde você tem menos ferramentas pra debugar. Aí às 3 da manhã o servidor cai com OOM (Out of Memory), e você fica olhando pro heap dump sem saber por onde começar.

Neste guia, vou te ajudar a parar de ter medo de memory leak e começar a caçar esses problemas de forma sistemática. Vamos entender os conceitos básicos, passar por sessões práticas de debug com ferramentas reais, ver os padrões de leak mais comuns no Node.js, e construir um framework mental pra atacar esses problemas em qualquer codebase.


Entendendo a Memória no Node.js: A Fundação

Antes de podermos corrigir memory leaks, precisamos entender como o Node.js gerencia memória. O Node.js usa o motor JavaScript V8, que implementa gerenciamento automático de memória através de garbage collection.

O Modelo de Memória do V8

O V8 divide a memória em vários espaços:

┌─────────────────────────────────────────────────────────────────┐
│                     MEMÓRIA HEAP DO V8                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │     NEW SPACE       │    │          OLD SPACE              │ │
│  │   (Geração Jovem)   │    │       (Geração Velha)           │ │
│  │                     │    │                                 │ │
│  │  • Objetos de       │───▶│  • Objetos de longa vida        │ │
│  │    vida curta       │    │  • Promovidos do New Space      │ │
│  │  • GC rápido        │    │  • GC mais lento (Mark-Sweep)   │ │
│  │    (Scavenge)       │    │                                 │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    LARGE OBJECT     │    │         CODE SPACE              │ │
│  │       SPACE         │    │   (Funções JS compiladas)       │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

New Space (Geração Jovem): Onde novos objetos são alocados. Pequeno e frequentemente coletado pelo garbage collector usando um algoritmo "Scavenger" rápido.

Old Space (Geração Velha): Objetos que sobrevivem múltiplos ciclos de garbage collection no New Space são promovidos para cá. Coletados com menos frequência usando o algoritmo Mark-Sweep-Compact, mais lento.

O que é Memory Leak afinal?

Simplificando: memory leak é quando seu app aloca memória que não precisa mais, mas não consegue liberar. Em JavaScript, isso geralmente significa manter referência pra objetos que deveriam ter sido descartados.

O GC (garbage collector) só limpa objetos que não têm ninguém apontando pra eles. Se você acidentalmente mantém uma referência pra um objeto, ele nunca vai ser coletado, mesmo que você nunca mais use ele.

Padrões comuns que causam leaks:

  1. Variáveis globais que acumulam dados
  2. Closures que capturam variáveis sem querer
  3. Event listeners que são adicionados mas nunca removidos
  4. Caches sem política de evicção
  5. Timers (setInterval) que nunca são limpos
  6. Referências circulares em certos contextos

Reconhecendo um Memory Leak: Os Sintomas

Como você sabe se tem um memory leak versus apenas alto uso de memória? Procure por esses padrões:

Sintoma 1: O Padrão Dente de Serra que Dá Errado

Uso normal de memória no Node.js parece dente de serra: a memória sobe conforme objetos são alocados, depois cai bruscamente durante o garbage collection.

Padrão Normal de Memória:
     ▲
     │    /\    /\    /\    /\
     │   /  \  /  \  /  \  /  \
     │  /    \/    \/    \/    \
     └────────────────────────────▶
                 Tempo

Um memory leak mostra um padrão diferente: a linha base do dente de serra continua subindo:

Padrão de Memory Leak:
     ▲
     │                      /\
     │                 /\  /  \
     │            /\  /  \/
     │       /\  /  \/
     │  /\  /  \/
     │ /  \/
     └────────────────────────────▶
                 Tempo

Sintoma 2: Old Space Crescendo

Use a flag --expose-gc e force periodicamente o garbage collection enquanto loga a memória:

// memory-monitor.js if (global.gc) { setInterval(() => { global.gc(); const used = process.memoryUsage(); console.log({ heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB', heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB', external: Math.round(used.external / 1024 / 1024) + 'MB', rss: Math.round(used.rss / 1024 / 1024) + 'MB', }); }, 10000); }

Execute com:

node --expose-gc memory-monitor.js

Se heapUsed continua crescendo mesmo imediatamente após o GC, você tem um leak.

Sintoma 3: Tempos de Resposta Crescentes

Conforme o heap cresce, os ciclos de garbage collection ficam mais longos e frequentes. Isso se manifesta como latência crescente e "pausas" periódicas no handling de requests.


Ferramentas de Debug: Seu Arsenal

Node.js oferece várias ferramentas poderosas para debug de memória. Vamos explorar cada uma.

1. Chrome DevTools (O Peso Pesado)

Node.js se integra com Chrome DevTools para análise poderosa de heap.

Inicie sua app em modo inspect:

node --inspect server.js # ou para parar na primeira linha: node --inspect-brk server.js

Conecte do Chrome:

  1. Abra chrome://inspect no Chrome
  2. Clique em "inspect" sob seu alvo Node.js
  3. Navegue até a aba "Memory"

Tirando Heap Snapshots:

A técnica mais poderosa é comparar heap snapshots ao longo do tempo:

  1. Tire um snapshot no baseline (após o servidor iniciar)
  2. Execute a ação que você suspeita estar vazando (enviar requests, etc.)
  3. Force garbage collection (clique no ícone de lixeira)
  4. Tire outro snapshot
  5. Compare os snapshots

A view de comparação mostra quais objetos foram alocados entre os snapshots e nunca liberados.

2. Built-in do Node.js: process.memoryUsage()

Monitoramento de memória rápido e simples:

function logMemory(label = '') { const used = process.memoryUsage(); console.log(`Memory ${label}:`, { rss: `${Math.round(used.rss / 1024 / 1024)} MB`, heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`, external: `${Math.round(used.external / 1024 / 1024)} MB`, }); } // Use para delimitar operações suspeitas logMemory('antes da operação'); await suspiciousOperation(); logMemory('depois da operação');

Entendendo as métricas:

  • rss (Resident Set Size): Memória total alocada para o processo
  • heapTotal: Heap total alocado
  • heapUsed: Memória heap realmente utilizada
  • external: Memória usada por objetos C++ vinculados ao JavaScript (Buffers, etc.)

3. Heap Snapshots via Código

Você pode gerar heap snapshots programaticamente sem o Chrome DevTools:

const v8 = require('v8'); const fs = require('fs'); const path = require('path'); function takeHeapSnapshot() { const filename = path.join( __dirname, `heap-${Date.now()}.heapsnapshot` ); const snapshotStream = v8.writeHeapSnapshot(filename); console.log(`Heap snapshot salvo em: ${filename}`); return filename; } // Dispare via endpoint HTTP para debug em produção app.get('/debug/heap-snapshot', (req, res) => { const file = takeHeapSnapshot(); res.json({ file }); });

Você pode então carregar esses arquivos .heapsnapshot no Chrome DevTools para análise.

4. Clinic.js: A Suite Pronta para Produção

Clinic.js é uma suíte fantástica de ferramentas para análise de performance em Node.js:

npm install -g clinic # Detecta vários problemas incluindo memory leaks clinic doctor -- node server.js # Profiling de heap focado clinic heap -- node server.js # Flame graphs para profiling de CPU clinic flame -- node server.js

O Clinic Doctor vai rodar seu servidor sob carga e gerar um relatório visual destacando problemas potenciais.

5. Memwatch-next: Detecção de Leak no Código

Para detecção automatizada de leaks:

const memwatch = require('memwatch-next'); memwatch.on('leak', (info) => { console.error('Memory leak detectado:', info); // info.growth: bytes vazados // info.reason: por que é considerado um leak }); memwatch.on('stats', (stats) => { console.log('Estatísticas do GC:', stats); // Estatísticas detalhadas de garbage collection });

Os 7 Padrões Mortais de Leak (E Como Corrigir)

Padrão 1: O Cache Sem Limite

O Problema:

// ❌ Memory leak: cache cresce para sempre const cache = {}; function getCachedData(key) { if (cache[key]) { return cache[key]; } const data = expensiveComputation(key); cache[key] = data; // Nunca é removido! return data; }

A Correção: Use um cache LRU (Least Recently Used) com tamanho máximo:

// ✅ Corrigido: cache limitado com evicção const LRU = require('lru-cache'); const cache = new LRU({ max: 500, // Máximo 500 items maxAge: 1000 * 60 * 5, // Items expiram após 5 minutos updateAgeOnGet: true, // Resetar idade no acesso }); function getCachedData(key) { const cached = cache.get(key); if (cached !== undefined) { return cached; } const data = expensiveComputation(key); cache.set(key, data); return data; }

Padrão 2: Acumulação de Event Listeners

O Problema:

// ❌ Memory leak: adicionando listeners em hot path sem remoção function handleConnection(socket) { const onData = (data) => { processData(data); }; // Cada conexão adiciona um novo listener, nunca removido eventEmitter.on('data', onData); }

A Correção: Sempre remova listeners quando não forem mais necessários:

// ✅ Corrigido: remover listener ao desconectar function handleConnection(socket) { const onData = (data) => { processData(data); }; eventEmitter.on('data', onData); socket.on('close', () => { eventEmitter.removeListener('data', onData); }); }

Ou use once para listeners de uso único:

eventEmitter.once('data', onData);

Padrão 3: Closures Capturando Contexto

O Problema:

// ❌ Memory leak: closure captura dados grandes function processRequest(req, res) { const largePayload = req.body; // 10MB de dados // Esta closure captura largePayload someAsyncOperation(() => { // Só usa req.body.id, mas o payload inteiro é retido console.log('Processado:', req.body.id); res.send('done'); }); }

A Correção: Extraia só o que você precisa:

// ✅ Corrigido: só capturar dados necessários function processRequest(req, res) { const { id } = req.body; // Extrair só o necessário someAsyncOperation(() => { console.log('Processado:', id); // Só 'id' é capturado res.send('done'); }); }

Padrão 4: Timers Órfãos

O Problema:

// ❌ Memory leak: setInterval nunca é limpo class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } // Sem método de limpeza! Instância nunca pode ser coletada }

A Correção: Sempre forneça limpeza:

// ✅ Corrigido: limpeza explícita class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } destroy() { clearInterval(this.intervalId); } } // Uso const poller = new DataPoller('/api/data'); // Quando terminar: poller.destroy();

Padrão 5: Estado Global Crescente

O Problema:

// ❌ Memory leak: array global cresce para sempre const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, // ...potencialmente headers e body grandes }); next(); });

A Correção: Limite suas estruturas de dados:

// ✅ Corrigido: log limitado com rotação const MAX_LOG_SIZE = 1000; const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, }); // Remover entradas antigas while (requestLog.length > MAX_LOG_SIZE) { requestLog.shift(); } next(); });

Ou melhor ainda, use um ring buffer ou envie logs para armazenamento externo.

Padrão 6: Promises Esquecidas

O Problema:

// ❌ Leak potencial: promises sem tratamento de erro acumulam function fetchAll(urls) { urls.forEach(url => { fetch(url).then(response => { process(response); }); // Sem .catch() - rejeições não tratadas acumulam referências }); }

A Correção: Sempre trate rejeições de promises:

// ✅ Corrigido: tratamento de erro apropriado async function fetchAll(urls) { const promises = urls.map(async url => { try { const response = await fetch(url); await process(response); } catch (error) { console.error(`Falha ao buscar ${url}:`, error); // Tratado apropriadamente, sem acumulação } }); await Promise.all(promises); }

Padrão 7: Referências Circulares com Closures

O Problema:

// ❌ Leak complicado: referência circular através de closure function createConnection() { const connection = { data: new Array(1000000).fill('x'), // Dados grandes }; connection.onClose = function() { // Esta closure referencia 'connection', criando referência circular console.log('Conexão fechada', connection.id); cleanup(connection); }; return connection; }

O V8 moderno lida bem com a maioria das referências circulares, mas closures podem prevenir a coleta:

A Correção: Quebre o ciclo explicitamente:

// ✅ Corrigido: quebrar referência circular na limpeza function createConnection() { const connection = { data: new Array(1000000).fill('x'), }; connection.onClose = function() { console.log('Conexão fechada', connection.id); cleanup(connection); connection.onClose = null; // Quebrar o ciclo connection.data = null; // Liberar dados grandes }; return connection; }

Uma Sessão de Debug do Mundo Real

Vamos percorrer passo a passo o debug de um cenário realista de memory leak.

O Cenário

Você tem um servidor Express que lida com conexões WebSocket. A memória continua crescendo.

// server.js - O código com leak const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); // Suspeito: armazenamento global para conexões const connections = new Map(); // Suspeito: histórico de mensagens global const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { // Armazenar todas as mensagens para sempre messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // Broadcast para todos connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // BUG: Sem limpeza quando a conexão fecha! }); app.listen(3000);

Passo 1: Confirmar o Leak

Adicione monitoramento de memória:

setInterval(() => { const used = process.memoryUsage(); console.log(`[Memory] Heap: ${Math.round(used.heapUsed / 1024 / 1024)}MB, ` + `Connections: ${connections.size}, ` + `Messages: ${messageHistory.length}`); }, 5000);

Depois de rodar com tráfego simulado, você vê:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 67MB, Connections: 98, Messages: 2500
[Memory] Heap: 89MB, Connections: 102, Messages: 4200
[Memory] Heap: 112MB, Connections: 95, Messages: 6100

A memória cresce mesmo com a contagem de conexões estável. O array messageHistory é o culpado óbvio aqui, mas vamos também verificar as conexões.

Passo 2: Tirar Heap Snapshots

Usando Chrome DevTools:

  1. Conectar ao node --inspect server.js
  2. Tirar snapshot após inicialização
  3. Simular 100 conexões, desconectá-las
  4. Tirar outro snapshot
  5. Comparar

Na view de comparação, você pode ver:

  • Muitos objetos Array (o histórico de mensagens)
  • Objetos WebSocket que não foram removidos do Map

Passo 3: Aplicar Correções

// server.js - Versão corrigida const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); const connections = new Map(); // Corrigido: histórico de mensagens limitado const MAX_HISTORY = 1000; const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // Remover mensagens antigas while (messageHistory.length > MAX_HISTORY) { messageHistory.shift(); } connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // Corrigido: limpeza ao fechar ws.on('close', () => { connections.delete(userId); }); ws.on('error', () => { connections.delete(userId); }); }); app.listen(3000);

Passo 4: Verificar a Correção

Execute o mesmo teste de carga. A memória agora deve estabilizar:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 52MB, Connections: 98, Messages: 1000
[Memory] Heap: 49MB, Connections: 102, Messages: 1000
[Memory] Heap: 51MB, Connections: 95, Messages: 1000

Vitória!


Estratégias de Produção

1. Limites de Memória e Alertas

Defina limites de memória explícitos para prevenir crashes descontrolados:

node --max-old-space-size=512 server.js # limite de 512MB

Implemente alertas:

const MEMORY_THRESHOLD = 450 * 1024 * 1024; // 450MB setInterval(() => { const used = process.memoryUsage(); if (used.heapUsed > MEMORY_THRESHOLD) { alertOperations('Limite de memória excedido', { heapUsed: used.heapUsed, threshold: MEMORY_THRESHOLD, }); } }, 60000);

2. Reinicializações Graciosas

Quando a memória fica muito alta, reinicialize graciosamente:

const RESTART_THRESHOLD = 500 * 1024 * 1024; setInterval(() => { if (process.memoryUsage().heapUsed > RESTART_THRESHOLD) { console.log('Limite de memória excedido, iniciando shutdown gracioso'); // Parar de aceitar novas conexões server.close(() => { // Permitir que requests existentes terminem setTimeout(() => { process.exit(0); // PM2 ou orquestrador de container vai reiniciar }, 5000); }); } }, 60000);

3. Endpoints de Health Check

Exponha métricas de memória para monitoramento:

app.get('/health', (req, res) => { const memory = process.memoryUsage(); const uptime = process.uptime(); res.json({ status: 'ok', uptime: Math.round(uptime), memory: { heapUsed: Math.round(memory.heapUsed / 1024 / 1024), heapTotal: Math.round(memory.heapTotal / 1024 / 1024), rss: Math.round(memory.rss / 1024 / 1024), }, // Métricas customizadas connections: connections.size, cacheSize: cache.size, }); });

4. Heap Dump Sob Demanda

Habilite heap dumps em produção para análise post-mortem:

app.post('/debug/heap', authenticateAdmin, (req, res) => { const v8 = require('v8'); const filename = v8.writeHeapSnapshot(); res.json({ filename }); });

Prevenção: Escrevendo Código Resistente a Leaks

Regra 1: Sempre Limpar Event Listeners

// Use AbortController para limpeza fácil const controller = new AbortController(); element.addEventListener('click', handler, { signal: controller.signal }); // Depois: remove todos os listeners adicionados com este controller controller.abort();

Regra 2: Limitar Todas as Coleções

// Em vez de arrays ilimitados const items = []; items.push(newItem); // Use coleções limitadas const MAX_ITEMS = 10000; if (items.length >= MAX_ITEMS) { items.shift(); } items.push(newItem);

Regra 3: Usar WeakMap e WeakSet para Metadata

Quando anexar metadata a objetos:

// ❌ Cria referências fortes, previne GC const metadata = new Map(); metadata.set(someObject, { extra: 'data' }); // ✅ Referências fracas, não previne GC const metadata = new WeakMap(); metadata.set(someObject, { extra: 'data' }); // Quando someObject não é mais referenciado em outro lugar, // a entrada de metadata é automaticamente removida

Regra 4: Implementar Padrões de Dispose

class ResourceManager { #resources = []; #disposed = false; acquire(resource) { if (this.#disposed) { throw new Error('Manager foi disposed'); } this.#resources.push(resource); return resource; } dispose() { this.#disposed = true; for (const resource of this.#resources) { resource.close?.(); resource.destroy?.(); } this.#resources.length = 0; // Limpar array } }

Conclusão: A Mudança de Mentalidade

Debugar memory leaks requer uma mudança na forma como você pensa sobre seu código. Em vez de apenas perguntar "isso funciona?", você precisa perguntar:

  1. Para onde esses dados vão? Toda alocação deve ter um ciclo de vida claro.
  2. Quando isso é limpo? Todo add deve ter seu correspondente remove.
  3. Qual é o limite superior? Toda coleção deve ter um tamanho máximo.
  4. O que mantém referências para isso? Entenda seu grafo de referências.

Memory leaks não são misteriosos. São apenas objetos sendo mantidos por mais tempo do que necessário. Com as ferramentas e padrões deste guia, você pode sistematicamente caçar qualquer leak e construir aplicações que rodem de forma confiável por meses sem reinicialização.

Da próxima vez que a memória do seu servidor começar a subir, você vai saber exatamente onde procurar—e exatamente como corrigir.


Feliz debugging. Que seus heaps permaneçam pequenos e seu garbage collector fique ocioso.

nodejsmemory-leakdebuggingperformancejavascriptbackenddevops

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit