Memory Leaks no Node.js acontecem quando objetos que já deveriam ser liberados continuam acessíveis e permanecem na memória. O processo pode funcionar normalmente durante minutos ou horas, mas o heap cresce, o garbage collector trabalha com mais frequência, a latência aumenta e, em casos extremos, o sistema encerra com erro de falta de memória.
Nem todo crescimento é vazamento. Caches, filas, buffers, pools e aquecimento do runtime podem aumentar o consumo de forma legítima. O diagnóstico correto exige observar tendência, reproduzir carga, comparar snapshots e descobrir por que determinados objetos continuam retidos.
Neste guia, você aprenderá a identificar sinais de vazamento, medir heap e RSS, usar snapshots, investigar listeners, timers, closures e caches, proteger a coleta em produção e criar testes que detectam crescimento contínuo.
Como a memória do Node.js funciona?
O processo utiliza diferentes áreas:
- heap do V8: objetos JavaScript;
- stack: frames de execução;
- external: memória ligada a objetos nativos;
- ArrayBuffers: buffers e dados binários;
- RSS: memória residente total do processo.
O comando abaixo mostra uma visão rápida:
const memory = process.memoryUsage();
console.log({
rssMB: memory.rss / 1024 / 1024,
heapUsedMB: memory.heapUsed / 1024 / 1024,
heapTotalMB: memory.heapTotal / 1024 / 1024,
externalMB: memory.external / 1024 / 1024,
arrayBuffersMB: memory.arrayBuffers / 1024 / 1024
});Para entender APIs relacionadas ao runtime, consulte Módulo V8 no Node.js.
O que caracteriza um vazamento?
Um vazamento costuma apresentar crescimento persistente após ciclos equivalentes de trabalho. A aplicação recebe carga, aloca objetos, executa garbage collection, mas a linha de base não retorna. Depois de cada ciclo, parte adicional permanece retida.
Um padrão típico é:
- heap começa em 100 MB;
- carga eleva para 180 MB;
- GC reduz para 120 MB;
- novo ciclo sobe para 200 MB;
- GC reduz apenas para 140 MB;
- a base continua crescendo.
Nem todo crescimento é vazamento
O V8 pode aumentar o heap reservado para reduzir coletas frequentes. O RSS também pode permanecer alto porque o sistema operacional e o allocator não devolvem páginas imediatamente. Avalie o heapUsed após garbage collections e compare ao longo de tempo suficiente.
Métricas contínuas
setInterval(() => {
const m = process.memoryUsage();
logger.info({
rss: m.rss,
heapUsed: m.heapUsed,
heapTotal: m.heapTotal,
external: m.external,
arrayBuffers: m.arrayBuffers
}, 'memory_usage');
}, 30_000).unref();Não use IDs ou dados sensíveis como labels de métricas. Para exportar valores, veja Métricas Prometheus no Node.js.
Heap snapshot
O guia oficial Using Heap Snapshot do Node.js explica diferentes formas de captura. Um snapshot registra objetos e relações de retenção.
O método mais simples localmente:
node --inspect server.jsAbra o DevTools, acesse a aba Memory e capture um snapshot.
Atenção ao risco do snapshot
A captura pausa o trabalho da thread principal e pode exigir memória adicional próxima ao tamanho do heap. Em um processo grande, isso pode causar indisponibilidade ou encerramento. Em produção, capture apenas de uma réplica que possa falhar sem afetar o serviço.
Snapshot por sinal
node --heapsnapshot-signal=SIGUSR2 server.jsDepois:
kill -USR2 PIDProteja o acesso ao host e ao mecanismo de sinal. Snapshots podem conter strings, tokens e dados pessoais presentes na memória.
Snapshot pela API V8
import { writeHeapSnapshot } from 'node:v8';
const file = writeHeapSnapshot();
console.log(file);Não exponha um endpoint público que executa essa função. Se precisar de acionamento remoto, use autenticação forte, rede administrativa e rate limit.
Comparando snapshots
Para encontrar crescimento:
- inicie o processo e aguarde o bootstrap;
- execute uma vez o fluxo suspeito para aquecer;
- capture o primeiro snapshot;
- repita apenas o fluxo suspeito várias vezes;
- capture o segundo snapshot;
- abra ambos no Chrome DevTools;
- use o modo Comparison;
- investigue deltas positivos e caminhos de retenção.
O objetivo não é procurar apenas objetos grandes, mas entender quem os mantém acessíveis.
Listeners não removidos
function attach(socket) {
const handler = message => {
processMessage(socket.id, message);
};
bus.on('message', handler);
}Se cada conexão adiciona listener e nunca remove, o bus mantém handlers e sockets.
function attach(socket) {
const handler = message => {
processMessage(socket.id, message);
};
bus.on('message', handler);
socket.once('close', () => {
bus.off('message', handler);
});
}Veja também EventEmitter no Node.js.
Timers esquecidos
function startJob(context) {
setInterval(() => {
sync(context);
}, 10_000);
}O interval mantém context acessível. Guarde a referência e finalize:
const timer = setInterval(run, 10_000);
function stop() {
clearInterval(timer);
}Para timers que não devem manter o processo ativo:
timer.unref();Consulte Timers no Node.js.
Caches sem limite
const cache = new Map();
function remember(key, value) {
cache.set(key, value);
}Um Map global sem TTL ou limite cresce indefinidamente. Defina tamanho máximo, expiração e estratégia de remoção.
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 10_000,
ttl: 5 * 60 * 1000
});Monitore quantidade de entradas e taxa de evicção.
Closures com objetos grandes
function buildHandler(largeDataset) {
return function handler(req, res) {
res.json({ count: largeDataset.length });
};
}A closure mantém todo o dataset, mesmo que apenas o tamanho seja necessário.
function buildHandler(largeDataset) {
const count = largeDataset.length;
return function handler(req, res) {
res.json({ count });
};
}Filas internas sem backpressure
Uma fila que recebe mais rápido do que processa não é necessariamente vazamento, mas produz crescimento semelhante. Meça tamanho, idade do item mais antigo e throughput.
if (queue.length > MAX_QUEUE) {
throw new ServiceUnavailableError();
}Para processamento em fluxo, consulte Streams no Node.js.
Buffers e memória externa
Se external ou arrayBuffers cresce enquanto o heap permanece estável, investigue Buffers, uploads, compressão e bibliotecas nativas.
const buffers = [];
app.post('/upload', async req => {
const body = await readAll(req);
buffers.push(body);
});Esse exemplo retém todos os uploads. Prefira streaming e descarte referências após uso.
Promessas pendentes
Promises que nunca concluem podem reter closures e contexto:
const pending = new Map();
function waitForResponse(id) {
return new Promise(resolve => {
pending.set(id, resolve);
});
}Adicione timeout e remoção:
function waitForResponse(id, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('Timeout'));
}, timeoutMs);
pending.set(id, value => {
clearTimeout(timer);
pending.delete(id);
resolve(value);
});
});
}Recursos nativos não fechados
Sockets, arquivos, cursores e conexões podem manter recursos vivos. Use finally e rotinas de shutdown.
const file = await open(path, 'r');
try {
await processFile(file);
} finally {
await file.close();
}Veja Graceful Shutdown no Node.js.
WeakMap e WeakRef
WeakMap permite associar dados a objetos sem impedir a coleta da chave. É útil para metadados auxiliares.
const metadata = new WeakMap();
metadata.set(request, { startedAt: Date.now() });Não use WeakRef como solução automática. O momento da coleta não é determinístico e regras de negócio não devem depender disso.
Teste de longa duração
Crie um cenário repetível com carga constante. O artigo Autocannon no Node.js mostra como gerar tráfego.
npx autocannon \
--connections 20 \
--duration 900 \
http://localhost:3000/api/reportRegistre memória a cada intervalo e procure crescimento da linha de base.
Forçar GC em testes
Somente em ambiente de teste:
node --expose-gc memory-test.jsglobal.gc?.();
const before = process.memoryUsage().heapUsed;
await runScenarioManyTimes();
global.gc?.();
const after = process.memoryUsage().heapUsed;
console.log({ delta: after - before });O teste é ruidoso e não deve usar limite rígido demais. Repita e combine com snapshots.
Limite de heap
node --max-old-space-size=2048 server.jsAumentar o limite pode adiar o crash, mas não corrige vazamento. Use apenas quando o workload legítimo exige mais memória e a capacidade do host é suficiente.
Alertas
Alertas úteis combinam:
- heap usado em relação ao limite;
- crescimento sustentado;
- frequência e duração de GC;
- RSS em relação ao limite do container;
- reinícios por OOM;
- latência e event loop lag.
Containers e OOM
Configure limites compatíveis com o heap e memória externa. Um processo pode ser encerrado pelo kernel antes de o V8 atingir seu limite. Reserve espaço para native addons, buffers, código e overhead do runtime.
Segurança dos artefatos
Heap snapshots podem conter senhas, tokens, headers e conteúdo de requisições. Trate arquivos como dados sensíveis:
- armazene com criptografia;
- restrinja acesso;
- não envie para repositório;
- defina retenção curta;
- apague após análise;
- registre quem coletou.
Erros comuns
- Olhar apenas RSS: não identifica a origem.
- Capturar um único snapshot: falta comparação.
- Confundir cache com vazamento: crescimento pode ser intencional.
- Aumentar o heap: apenas adia o problema.
- Snapshot na única réplica: pode causar indisponibilidade.
- Ignorar memória externa: buffers podem dominar.
- Teste curto: vazamentos pequenos não aparecem.
Conclusão
Memory Leaks no Node.js exigem investigação baseada em tendência e retenção. Métricas mostram que a memória cresce; snapshots e caminhos de referência mostram por que ela não é liberada.
Monitore heap, RSS e memória externa, reproduza o fluxo suspeito, compare snapshots e revise listeners, timers, caches, closures, filas e recursos nativos. Com testes de longa duração e coleta segura, vazamentos deixam de ser um problema misterioso e se tornam um conjunto de referências que pode ser identificado e corrigido.




