Erro de memory leak em servidor Node.js exibido em terminal de data center - debug de produção

Memory Leak em Node.js: Como 45MB/hora Derrubaram Meu Serviço de Madrugada (E o Heap Snapshot Que Revelou o Culpado)

Era 3h27 da madrugada de uma terça-feira quando meu celular vibrou com a notificação que nenhum dev quer receber: “Container node-api restartou pelo OOM killer — uso de memória excedeu 512MB.” O serviço estava no ar há 47 dias sem um único incidente. Até que, do nada, decidiu comer RAM como se não houvesse amanhã.

Se você já teve um memory leak em Node.js de produção, sabe o sentimento: pânico, followed by negação, followed by console.log() desesperado. O problema é que memory leak é o tipo de bug que não aparece em teste. Ele é um fantasma que só se manifesta sob carga real, com tempo de execução suficiente pra acumular.

Neste artigo, eu vou te mostrar exatamente como eu rastreei, isolei e matei esse memory leak — sem ferramentas pagas, sem APM enterprise, usando só o que o Node.js já te dá de graça. E no caminho, você vai entender por que 90% dos memory leaks em JavaScript seguem o mesmo padrão.

Racks de servidores em data center - monitoramento de infraestrutura de produção com memory leak
Os racks não avisam quando a memória começa a vazar. Só o OOM killer.

O Que É Um Memory Leak (E Por Que Node.js É Especialmente Vulnerável)

Memory leak acontece quando seu código aloca memória que o garbage collector (GC) nunca consegue liberar. Em linguagens com gerenciamento manual de memória (C, C++), o dev decide quando liberar. Em JavaScript, você confia no V8.

O problema? O V8 é esperto, mas não é telepata. Ele só libera objetos que não têm mais referência. Se você manter uma referência escondida — mesmo que nunca mais use aquele objeto — ele fica preso na heap forever.

Node.js é especialmente vulnerável por três motivos:

  • Servidores rodam por dias/semanas sem restart. Um leak de 1MB/hora vira 1.44GB em 2 meses.
  • Closures e callbacks acumulam estado. Cada request que captura variável no closure é uma potencial armadilha.
  • Event listeners e caches caseiros são os piores ofensores. Ninguém percebe até o processo explodir.

O Cenário do Crime: Minha API em Produção

Antes de mergulhar no debug, contexto: a API em questão era um serviço de processamento de webhooks. Recebia payloads de até 500KB, fazia validação, gravava no PostgreSQL e retornava 200. Coisa simples. Rails da vida.

A stack:

  • Node.js 20 LTS (com --max-old-space-size=512)
  • Express 4.x
  • pg (node-postgres) com connection pool
  • Redis para cache de deduplicação
  • Docker no ECS com health check básico

O primeiro sinal foi nos gráficos do CloudWatch. A memória do container subia numa linha reta elegante — 80MB no deploy, 512MB em 5 dias, OOM kill, restart, recomeça. Like clockwork.

🧨 Box Perrengue: O mais irônico? Eu tinha configurado alerta de memória. Mas o threshold era 80% — e o leak era tão graduoso que nunca chegava lá durante horário comercial. Só estourava de madrugada, quando ninguém olhava o dashboard. Resultado: 3 semanas de restarts noturnos silenciosos antes de alguém perceber.

Passo 1: Confirmar Que É Realmente Um Leak (E Não Só “Carga Normal”)

Antes de sair caçando, confirme. Muita gente confunde peak de memória sob carga com leak de verdade. A diferença é simples:

  • Carga normal: memória sobe, depois estabiliza em um platô.
  • Leak real: memória sobe e nunca volta, mesmo com carga zero.

O teste que eu fiz:

# Gerei tráfego artificial com autocannon
npx autocannon -c 50 -d 60 http://localhost:3000/webhook

# Enquanto isso, monitorei a heap
node -e "const v8 = require('v8');
  setInterval(() => {
    const stats = v8.getHeapStatistics();
    console.log(new Date().toISOString(),
      'heap_used:', Math.round(stats.used_heap_size / 1024 / 1024) + 'MB',
      'heap_limit:', Math.round(stats.heap_size_limit / 1024 / 1024) + 'MB');
  }, 5000);"

Resultado: após o burst de 60 segundos, a heap caiu de 142MB para 89MB — o GC funcionou. Mas ao longo de horas, o piso ia subindo: 89 → 94 → 101 → 112MB. O GC limpava o pico, mas nunca voltava ao baseline original. Esse é o smoking gun.

Passo 2: Tirar Heap Snapshot Sem Derrubar o Serviço

Aqui é onde a maioria das pessoas desiste. “Preciso reiniciar o servidor com --inspect?” Não. O Node.js tem API nativa pra isso.

// Adicione isso temporariamente no seu servidor
const v8 = require('v8');
const fs = require('fs');

// Endpoint escondido pra tirar snapshot
app.get('/__debug/heapdump', (req, res) => {
  // Proteja com secret header na vida real!
  if (req.headers['x-debug-secret'] !== process.env.DEBUG_SECRET) {
    return res.status(404).end();
  }

  const filename = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
  v8.writeHeapSnapshot(filename);

  res.json({
    message: 'Heap snapshot salvo',
    file: filename,
    heapSize: Math.round(v8.getHeapStatistics().used_heap_size / 1024 / 1024) + 'MB'
  });
});

Eu tirei dois snapshots com 1 hora de diferença:

curl -H "x-debug-secret: meu-secret" http://localhost:3000/__debug/heapdump
# {"message":"Heap snapshot salvo","file":"/tmp/heapdump-1745683200000.heapsnapshot","heapSize":"89MB"}

# ... 1 hora depois ...

curl -H "x-debug-secret: meu-secret" http://localhost:3000/__debug/heapdump
# {"message":"Heap snapshot salvo","file":"/tmp/heapdump-1745686800000.heapsnapshot","heapSize":"134MB"}

45MB de diferença em 1 hora. Agora eu tinha os arquivos pra comparar.

Terminal de debugging em laptop - análise de memory leak Node.js em produção
O momento em que você carrega o heap snapshot e vê 47.000 instâncias do mesmo objeto. Prioridade: não entrar em pânico.

Passo 3: Comparação com Chrome DevTools (O Segredo Está Nos Retained Objects)

Transferi os arquivos .heapsnapshot pra minha máquina e abri no Chrome DevTools (Memory → Load). A mágica é a view “Comparison” — carrega o snapshot 1 como baseline, depois o snapshot 2, e ele te mostra exatamente o que cresceu.

O que eu vi na view “Delta”:

  • Object — +12.847 instâncias
  • WebhookPayload — +12.831 instâncias (suspeito demais)
  • Array — +6.420
  • cachedResponse — +12.800

WebhookPayload com 12.831 instâncias novas em 1 hora? Cada webhook recebido = 1 payload. Eu processava ~200 webhooks/minuto. Em 1 hora: 12.000. Os números batiam.

A questão era: quem está segurando essas referências?

Passo 4: Caçar o Retaining Path

No Chrome DevTools, cliquei num objeto WebhookPayload e expandi o “Retainers” — a cadeia de referências que impede o GC de limpar.

O retaining path revelou:

WebhookPayload
  → Array (contextos acumulados)
    → Map.cacheStore
      → deduplicationCache (module scope)
        → global

BINGO. O culpado era meu cache de deduplicação.

O Código Problemático (E Por Que Ele Vaza)

Aqui está o código original, inocente à primeira vista:

// dedup-cache.js — O assassino silencioso
const cache = new Map();

function checkAndCache(webhookId, payload) {
  if (cache.has(webhookId)) {
    return cache.get(webhookId);
  }

  // Processa e armazena resultado
  const result = {
    id: webhookId,
    payload: payload,        // ← O PROBLEMA
    processedAt: new Date(),
    status: 'processed'
  };

  cache.set(webhookId, result);
  return null; // não estava em cache, processa normalmente
}

module.exports = { checkAndCache };

Viu o problema? Eu guardo o payload inteiro (até 500KB) dentro do objeto de cache. E nunca limpo o cache. Cada webhook que passa por aqui deixa um fantasma de 500KB na memória. Para sempre.

500KB × 12.000 webhooks/hora = 6GB/hora de leak. Com o limite de 512MB, o container morria em menos de 1 hora.

Mas espera — eu disse que o container durava 5 dias, não 1 hora. Isso porque na verdade nem todo webhook era novo. Muitos eram duplicados (por isso o cache existia). O leak real era mais lento — cerca de 80-100MB/dia — mas igualmente fatal.

🧨 Box Perrengue: Quando eu contei isso pro meu colega, ele perguntou: “Mas por que você guardava o payload inteiro no cache?” Resposta honesta: porque eu copiei de um gist do GitHub sem pensar. O cache só precisava do webhookId e do timestamp. Eu estava guardando o equivalente a guardar toda a mala do aeroporto só pra lembrar o número do voo.

A Solução: Cache com TTL e Cleanup Automático

Primeiro, a correção imediata — parar de armazenar o payload:

// dedup-cache.js v2 — Agora sem vazamento
const cache = new Map();
const TTL_MS = 5 * 60 * 1000; // 5 minutos de TTL

function checkAndCache(webhookId) {
  // Cleanup expirados a cada verificação (lazy cleanup)
  const now = Date.now();
  for (const [key, entry] of cache) {
    if (now - entry.timestamp > TTL_MS) {
      cache.delete(key);
    }
  }

  if (cache.has(webhookId)) {
    return true; // já processado
  }

  cache.set(webhookId, { timestamp: now });
  return false; // pode processar
}

module.exports = { checkAndCache };

Mas lazy cleanup tem problema: se você receber um burst de webhooks únicos, o cache cresce antes do cleanup rodar. Solução robusta com intervalo dedicado:

// cache-manager.js — Para produção de verdade
class TTLCache {
  constructor({ ttlMs = 300000, maxSize = 10000, cleanupIntervalMs = 60000 }) {
    this.cache = new Map();
    this.ttlMs = ttlMs;
    this.maxSize = maxSize;

    // Cleanup periódico
    this.cleanupTimer = setInterval(() => this._cleanup(), cleanupIntervalMs);

    // Se o timer for o único thing segurando o event loop,
    // permite que o processo encerre graciosamente
    if (this.cleanupTimer.unref) {
      this.cleanupTimer.unref();
    }
  }

  set(key, value) {
    // Evict oldest se passar do tamanho máximo
    if (this.cache.size >= this.maxSize) {
      const oldestKey = this.cache.keys().next().value;
      this.cache.delete(oldestKey);
    }

    this.cache.set(key, {
      value,
      expiresAt: Date.now() + this.ttlMs
    });
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return undefined;
    }

    return entry.value;
  }

  has(key) {
    return this.get(key) !== undefined;
  }

  _cleanup() {
    const now = Date.now();
    for (const [key, entry] of this.cache) {
      if (now > entry.expiresAt) {
        this.cache.delete(key);
      }
    }
  }

  destroy() {
    clearInterval(this.cleanupTimer);
    this.cache.clear();
  }
}

module.exports = { TTLCache };

Notou o .unref() no timer? Sem isso, o setInterval mantém o event loop vivo mesmo quando o resto do app quer encerrar. É um detalhe que causa outro tipo de leak: o processo não morre nunca.

Passo 5: Monitoramento Contínuo (Pra Nunca Mais Ser Pego de Surpresa)

Corrigir uma vez não basta. Precisa de monitoramento contínuo da heap. Criei um middleware que loga métricas de memória a cada 100 requests:

// memory-monitor.js
const v8 = require('v8');

let requestCount = 0;
const LOG_INTERVAL = 100; // a cada 100 requests

function memoryMonitor(req, res, next) {
  requestCount++;

  if (requestCount % LOG_INTERVAL === 0) {
    const stats = v8.getHeapStatistics();
    const usedMB = Math.round(stats.used_heap_size / 1024 / 1024);
    const limitMB = Math.round(stats.heap_size_limit / 1024 / 1024);
    const usagePercent = Math.round((usedMB / limitMB) * 100);

    console.log(JSON.stringify({
      type: 'memory_stats',
      requests: requestCount,
      heapUsedMB: usedMB,
      heapLimitMB: limitMB,
      usagePercent,
      rssMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
      externalMB: Math.round(process.memoryUsage().external / 1024 / 1024),
      timestamp: new Date().toISOString()
    }));

    // Alerta proactivo
    if (usagePercent > 75) {
      console.error(JSON.stringify({
        type: 'memory_warning',
        message: `Heap usage em ${usagePercent}% — possível leak`,
        heapUsedMB: usedMB,
        timestamp: new Date().toISOString()
      }));
    }
  }

  next();
}

module.exports = { memoryMonitor };

Esse middleware manda logs estruturados que qualquer agregador (CloudWatch, Datadog, Loki) consegue consumir. E o threshold de 75% dá tempo de agir antes do OOM killer agir por você.

Padrões Comuns de Memory Leak em Node.js (E Como Evitar)

Depois desse perrengue, fiz uma auditoria em todos os meus serviços Node.js. Os padrões que mais apareciam:

1. Closures em Event Listeners

// ❌ LEAK: cada registro cria listener que captura 'data'
function processUsers(users) {
  users.forEach(user => {
    emitter.on('event', (payload) => {
      processData(user, payload); // 'user' fica preso no closure
    });
  });
}

// ✅ CORRETO: usa WeakRef ou remove listener
function processUsers(users) {
  const handler = (payload) => processData(payload);
  emitter.on('event', handler);

  // Quando terminar
  cleanup = () => emitter.off('event', handler);
}

2. Propriedades Globais Acumulativas

// ❌ LEAK: array global sem limite
const requestLog = [];
app.use((req, res, next) => {
  requestLog.push({ url: req.url, time: Date.now() });
  next();
});

// ✅ CORRETO: buffer circular
const requestLog = [];
const MAX_LOG = 1000;
app.use((req, res, next) => {
  if (requestLog.length >= MAX_LOG) requestLog.shift();
  requestLog.push({ url: req.url, time: Date.now() });
  next();
});

3. Promises Não-Resolvidas

// ❌ LEAK: promise que nunca resolve nem rejeita
function brokenFetch(url) {
  return new Promise((resolve) => {
    // Esqueceu de chamar resolve() em algum path
    if (shouldSkip) return; // ← promise pendente forever
    fetch(url).then(resolve);
  });
}

// ✅ CORRETO: sempre resolve ou reject em todos os paths
function safeFetch(url) {
  return new Promise((resolve, reject) => {
    if (shouldSkip) return resolve(null);
    fetch(url).then(resolve).catch(reject);
  });
}

4. Timers Esquecidos

// ❌ LEAK: setInterval que nunca é limpo
function startPolling() {
  setInterval(() => checkStatus(), 5000);
  // Se startPolling for chamada de novo = 2 intervalos rodando
}

// ✅ CORRETO: retorna handle para cleanup
function startPolling() {
  const timer = setInterval(() => checkStatus(), 5000);
  return () => clearInterval(timer);
}

Checklist Anti-Leak Para Produção

Antes de qualquer deploy de serviço Node.js de longa duração, eu verifico:

  • ☑ Todos os Map/Cache têm TTL ou limite de tamanho. Cache sem expiração é bomba-relógio.
  • ☑ Event listeners são removidos. Use emitter.off() ou once() em vez de on() permanente.
  • ☑ Nenhuma Promise fica pendente. Todas as paths devem resolver ou rejeitar.
  • ☑ Timers (setInterval/setTimeout) são limpos no shutdown. Ou usam .unref().
  • ☑ Monitoramento de heap com threshold de alerta. O middleware acima resolve.
  • ☑ Limite de memória explícito no container. --max-old-space-size=384 pra um container de 512MB.
  • ☑ Load test com monitoramento de memória antes do deploy. autocannon + v8 heap stats.

As Ferramentas Que Salvaram Minha Noite (Todas Gratuitas)

Não precisa de APM caro pra debugar memory leak. O kit básico:

  • v8.writeHeapSnapshot() — Tire snapshot sem reiniciar o processo. Nativo do Node.js.
  • Chrome DevTools → Memory tab — Load do arquivo .heapsnapshot + view Comparison = detective work.
  • process.memoryUsage() — Monitora RSS, heap used, heap total, external, array buffers.
  • autocannon — Load test que você roda em 1 comando. npx autocannon -c 50 -d 60 URL
  • node --inspect — Conecta o DevTools remoto ao processo em produção (via SSH tunnel).
  • clinic.js — Suite de profiling do NearForm. npx clinic heapprofiler -- node app.js

O Resultado Final

Depois da correção, a memória do container ficou assim:

  • Antes: 80MB → 512MB em 5 dias → OOM kill → restart → repete
  • Depois: 78MB → 82MB (platô) → estável por 30+ dias sem restart

O cache de deduplicação continua funcionando. Apenas agora ele não guarda o payload inteiro, tem TTL de 5 minutos, e faz cleanup automático. Funciona melhor que antes, porque a hit rate não mudou (webhooks duplicados chegam em sequência, não com horas de diferença).

E o monitoramento de heap com threshold de 75% já me salvou uma segunda vez — quando uma dependência de terceiros começou a vazar após uma atualização. O alerta disparou 6 horas antes do crash. Seis horas de margem para agir.

🧨 Box Perrengue — O Bônus: Quando fui investigar o heap snapshot no Chrome DevTools, descobri que tinha 1.247 instâncias de uma classe RetryQueue de uma lib de HTTP que eu usava. A lib tinha um bug onde retries com backoff exponencial acumulavam timers internos que nunca eram limpos. Abri um issue no GitHub, mantenedor corrigiu em 3 dias. Open source é lindo quando funciona.

Lições Que Eu Queria Ter Aprendido Antes

Se eu pudesse mandar um email pro eu de 3 semanas atrás, diria:

  1. Cache sem limite é um leak com nome bonito. Toda estrutura de dados que só cresce vai explodir. Sempre.
  2. Heap snapshot é seu melhor amigo. Pare de tentar adivinhar onde está o leak. Tire dois snapshots, compare, deixe o DevTools te mostrar.
  3. O GC do V8 não é mágico. Ele limpa o que não tem referência. Seu trabalho é garantir que referências sejam limpas. O dele é limpar os objetos.
  4. Monitoramento de memória não é opcional. Se você não sabe quanto de heap seu serviço usa em produção, você está operando às cegas.
  5. Load test antes do deploy, não depois do incidente. 10 minutos de autocannon teriam revelado o leak em desenvolvimento.

Memory leak não é questão de se vai acontecer. É questão de quando. E quando acontecer, você vai querer saber exatamente como rastrear. Guarde esse artigo nos favoritos — um dia você vai precisar.

E você? Já tomou um susto com memory leak em produção? Qual foi a causa? Conta nos comentários — ou melhor, me diz qual automação ou debug de produção você quer ver aqui no Log de Erros. A próxima dor que a gente resolve pode ser a sua.

Posts Similares