Back

Depuración de Fugas de Memoria en Node.js: Guía Completa de Troubleshooting con Ejemplos Reales

Al principio todo parece normal. Tu servidor Node.js funciona bien por horas, días. Pero un día miras el dashboard de monitoreo y notas que el gráfico de memoria está subiendo... lento, pero subiendo. Reiniciar el servidor se vuelve rutina semanal. Luego diaria. De pronto estás escribiendo un cron job para reiniciar automáticamente cada pocas horas. Algo anda muy mal.

Las fugas de memoria en Node.js son de esos bugs que dan dolor de cabeza. A diferencia de un error de sintaxis que te explota en la cara, una fuga de memoria se esconde. Aparece solo bajo carga pesada, generalmente en producción donde tienes menos herramientas para debuggear. Y cuando a las 3 AM el servidor cae con OOM (Out of Memory), te quedas mirando el heap dump sin saber por dónde empezar.

En esta guía te voy a ayudar a perder el miedo a las fugas de memoria y empezar a cazarlas de forma sistemática. Vamos a entender los conceptos básicos, pasar por sesiones prácticas de debug con herramientas reales, ver los patrones de fuga más comunes en Node.js, y armar un framework mental para atacar estos problemas en cualquier codebase.


Entendiendo la Memoria en Node.js: Los Fundamentos

Antes de poder arreglar fugas de memoria, necesitamos entender cómo Node.js gestiona la memoria. Node.js usa el motor JavaScript V8, que implementa gestión automática de memoria a través del garbage collection.

El Modelo de Memoria de V8

V8 divide la memoria en varios espacios:

┌─────────────────────────────────────────────────────────────────┐
│                     MEMORIA HEAP DE V8                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │     NEW SPACE       │    │          OLD SPACE              │ │
│  │  (Generación Joven) │    │     (Generación Vieja)          │ │
│  │                     │    │                                 │ │
│  │  • Objetos de       │───▶│  • Objetos de larga vida        │ │
│  │    corta vida       │    │  • Promovidos desde New Space   │ │
│  │  • GC rápido        │    │  • GC más lento (Mark-Sweep)    │ │
│  │    (Scavenge)       │    │                                 │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    LARGE OBJECT     │    │         CODE SPACE              │ │
│  │       SPACE         │    │   (Funciones JS compiladas)     │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

New Space (Generación Joven): Donde se asignan los nuevos objetos. Pequeño y frecuentemente recolectado usando un rápido algoritmo "Scavenger".

Old Space (Generación Vieja): Los objetos que sobreviven múltiples ciclos de garbage collection en New Space son promovidos aquí. Se recolecta con menos frecuencia usando el algoritmo Mark-Sweep-Compact, más lento.

¿Qué es una Fuga de Memoria en realidad?

En pocas palabras: una fuga de memoria ocurre cuando tu app reserva memoria que ya no necesita, pero no logra liberarla. En JavaScript, esto típicamente significa mantener referencias a objetos que deberías haber descartado.

El GC (garbage collector) solo limpia objetos que no tienen a nadie apuntando hacia ellos. Si accidentalmente mantienes una referencia a un objeto, ese objeto nunca se va a limpiar, aunque nunca lo vuelvas a usar.

Patrones comunes que causan fugas:

  1. Variables globales que acumulan datos
  2. Closures que capturan variables sin intención
  3. Event listeners que se añaden pero nunca se eliminan
  4. Cachés sin política de expulsión
  5. Timers (setInterval) que nunca se limpian
  6. Referencias circulares en ciertos contextos

Reconociendo una Fuga de Memoria: Los Síntomas

¿Cómo sabes si tienes una fuga de memoria versus simplemente alto uso de memoria? Busca estos patrones:

Síntoma 1: El Patrón de Dientes de Sierra que Sale Mal

El uso normal de memoria en Node.js parece dientes de sierra: la memoria sube mientras se asignan objetos, luego cae bruscamente durante el garbage collection.

Patrón de Memoria Normal:
     ▲
     │    /\    /\    /\    /\
     │   /  \  /  \  /  \  /  \
     │  /    \/    \/    \/    \
     └────────────────────────────▶
                 Tiempo

Una fuga de memoria muestra un patrón diferente: la línea base del diente de sierra sigue subiendo:

Patrón de Fuga de Memoria:
     ▲
     │                      /\
     │                 /\  /  \
     │            /\  /  \/
     │       /\  /  \/
     │  /\  /  \/
     │ /  \/
     └────────────────────────────▶
                 Tiempo

Síntoma 2: Old Space Creciente

Usa el flag --expose-gc y fuerza periódicamente el garbage collection mientras registras la memoria:

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

Ejecuta con:

node --expose-gc memory-monitor.js

Si heapUsed sigue creciendo incluso inmediatamente después del GC, tienes una fuga.

Síntoma 3: Tiempos de Respuesta Crecientes

A medida que el heap crece, los ciclos de garbage collection se hacen más largos y frecuentes. Esto se manifiesta como latencia creciente y "pausas" periódicas en el manejo de peticiones.


Herramientas de Depuración: Tu Arsenal

Node.js ofrece varias herramientas potentes para la depuración de memoria. Exploremos cada una.

1. Chrome DevTools (El Peso Pesado)

Node.js se integra con Chrome DevTools para análisis de heap potente.

Inicia tu app en modo inspect:

node --inspect server.js # o para parar en la primera línea: node --inspect-brk server.js

Conecta desde Chrome:

  1. Abre chrome://inspect en Chrome
  2. Haz clic en "inspect" bajo tu objetivo Node.js
  3. Navega a la pestaña "Memory"

Tomando Heap Snapshots:

La técnica más poderosa es comparar heap snapshots a lo largo del tiempo:

  1. Toma un snapshot en la línea base (después de iniciar el servidor)
  2. Realiza la acción que sospechas que está fugando (enviar peticiones, etc.)
  3. Fuerza garbage collection (clic en el icono de papelera)
  4. Toma otro snapshot
  5. Compara los snapshots

La vista de comparación te muestra qué objetos fueron asignados entre snapshots y nunca liberados.

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

Monitoreo de memoria rápido y simple:

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`, }); } // Úsalo para delimitar operaciones sospechosas logMemory('antes de operación'); await suspiciousOperation(); logMemory('después de operación');

Entendiendo las métricas:

  • rss (Resident Set Size): Memoria total asignada para el proceso
  • heapTotal: Heap total asignado
  • heapUsed: Memoria heap realmente utilizada
  • external: Memoria usada por objetos C++ vinculados a JavaScript (Buffers, etc.)

3. Heap Snapshots vía Código

Puedes generar heap snapshots programáticamente sin 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 guardado en: ${filename}`); return filename; } // Dispara vía endpoint HTTP para depuración en producción app.get('/debug/heap-snapshot', (req, res) => { const file = takeHeapSnapshot(); res.json({ file }); });

Luego puedes cargar estos archivos .heapsnapshot en Chrome DevTools para análisis.

4. Clinic.js: La Suite Lista para Producción

Clinic.js es una suite fantástica de herramientas para análisis de rendimiento en Node.js:

npm install -g clinic # Detecta varios problemas incluyendo fugas de memoria clinic doctor -- node server.js # Profiling de heap enfocado clinic heap -- node server.js # Flame graphs para profiling de CPU clinic flame -- node server.js

Clinic Doctor ejecutará tu servidor bajo carga y generará un reporte visual resaltando problemas potenciales.

5. Memwatch-next: Detección de Fugas en Código

Para detección automatizada de fugas:

const memwatch = require('memwatch-next'); memwatch.on('leak', (info) => { console.error('Fuga de memoria detectada:', info); // info.growth: bytes fugados // info.reason: por qué se considera una fuga }); memwatch.on('stats', (stats) => { console.log('Estadísticas GC:', stats); // Estadísticas detalladas de garbage collection });

Los 7 Patrones de Fuga Mortales (Y Cómo Solucionarlos)

Patrón 1: El Caché Sin Límites

El Problema:

// ❌ Fuga de memoria: el caché crece para siempre const cache = {}; function getCachedData(key) { if (cache[key]) { return cache[key]; } const data = expensiveComputation(key); cache[key] = data; // ¡Nunca se expulsa! return data; }

La Solución: Usa un caché LRU (Least Recently Used) con un tamaño máximo:

// ✅ Corregido: caché limitado con expulsión const LRU = require('lru-cache'); const cache = new LRU({ max: 500, // Máximo 500 items maxAge: 1000 * 60 * 5, // Items expiran después de 5 minutos updateAgeOnGet: true, // Resetear edad en acceso }); function getCachedData(key) { const cached = cache.get(key); if (cached !== undefined) { return cached; } const data = expensiveComputation(key); cache.set(key, data); return data; }

Patrón 2: Acumulación de Event Listeners

El Problema:

// ❌ Fuga de memoria: añadiendo listeners en un hot path sin eliminarlos function handleConnection(socket) { const onData = (data) => { processData(data); }; // Cada conexión añade un nuevo listener, nunca se elimina eventEmitter.on('data', onData); }

La Solución: Siempre elimina los listeners cuando ya no se necesitan:

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

O usa once para listeners de una sola vez:

eventEmitter.once('data', onData);

Patrón 3: Closures Capturando Contexto

El Problema:

// ❌ Fuga de memoria: closure captura datos grandes function processRequest(req, res) { const largePayload = req.body; // 10MB de datos // Este closure captura largePayload someAsyncOperation(() => { // Solo usa req.body.id, pero todo el payload se retiene console.log('Procesado:', req.body.id); res.send('done'); }); }

La Solución: Extrae solo lo que necesitas:

// ✅ Corregido: solo capturar datos necesarios function processRequest(req, res) { const { id } = req.body; // Extraer solo lo necesario someAsyncOperation(() => { console.log('Procesado:', id); // Solo 'id' es capturado res.send('done'); }); }

Patrón 4: Timers Huérfanos

El Problema:

// ❌ Fuga de memoria: setInterval nunca se limpia class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } // ¡Sin método de limpieza! La instancia nunca puede ser recolectada }

La Solución: Siempre proporciona limpieza:

// ✅ Corregido: limpieza 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'); // Cuando termines: poller.destroy();

Patrón 5: Estado Global Creciente

El Problema:

// ❌ Fuga de memoria: array global crece para siempre const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, // ...potencialmente headers y body grandes }); next(); });

La Solución: Limita tus estructuras de datos:

// ✅ Corregido: log limitado con rotación const MAX_LOG_SIZE = 1000; const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, }); // Expulsar entradas antiguas while (requestLog.length > MAX_LOG_SIZE) { requestLog.shift(); } next(); });

O mejor aún, usa un ring buffer o envía los logs a almacenamiento externo.

Patrón 6: Promises Olvidadas

El Problema:

// ❌ Fuga potencial: promises sin manejo de errores se acumulan function fetchAll(urls) { urls.forEach(url => { fetch(url).then(response => { process(response); }); // Sin .catch() - rechazos sin manejar acumulan referencias }); }

La Solución: Siempre maneja los rechazos de promises:

// ✅ Corregido: manejo de errores apropiado async function fetchAll(urls) { const promises = urls.map(async url => { try { const response = await fetch(url); await process(response); } catch (error) { console.error(`Falló al obtener ${url}:`, error); // Manejado apropiadamente, sin acumulación } }); await Promise.all(promises); }

Patrón 7: Referencias Circulares con Closures

El Problema:

// ❌ Fuga complicada: referencia circular a través de closure function createConnection() { const connection = { data: new Array(1000000).fill('x'), // Datos grandes }; connection.onClose = function() { // Este closure referencia 'connection', creando referencia circular console.log('Conexión cerrada', connection.id); cleanup(connection); }; return connection; }

V8 moderno maneja bien la mayoría de referencias circulares, pero los closures pueden prevenir la recolección:

La Solución: Rompe el ciclo explícitamente:

// ✅ Corregido: romper referencia circular en limpieza function createConnection() { const connection = { data: new Array(1000000).fill('x'), }; connection.onClose = function() { console.log('Conexión cerrada', connection.id); cleanup(connection); connection.onClose = null; // Romper el ciclo connection.data = null; // Liberar datos grandes }; return connection; }

Una Sesión de Depuración del Mundo Real

Recorramos paso a paso la depuración de un escenario realista de fuga de memoria.

El Escenario

Tienes un servidor Express que maneja conexiones WebSocket. La memoria sigue creciendo.

// server.js - El código con fuga const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); // Sospechoso: almacenamiento global para conexiones const connections = new Map(); // Sospechoso: historial de mensajes global const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { // Almacenar todos los mensajes para siempre messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // Broadcast a todos connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // ¡BUG: Sin limpieza cuando la conexión se cierra! }); app.listen(3000);

Paso 1: Confirmar la Fuga

Añade monitoreo de memoria:

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

Después de ejecutar con tráfico simulado, ves:

[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

La memoria crece aunque el conteo de conexiones se mantiene estable. El array messageHistory es el culpable obvio aquí, pero también revisemos las conexiones.

Paso 2: Tomar Heap Snapshots

Usando Chrome DevTools:

  1. Conectar a node --inspect server.js
  2. Tomar snapshot después del inicio
  3. Simular 100 conexiones, desconectarlas
  4. Tomar otro snapshot
  5. Comparar

En la vista de comparación, podrías ver:

  • Muchos objetos Array (el historial de mensajes)
  • Objetos WebSocket que no fueron eliminados del Map

Paso 3: Aplicar Correcciones

// server.js - Versión corregida const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); const connections = new Map(); // Corregido: historial de mensajes 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(), }); // Expulsar mensajes antiguos while (messageHistory.length > MAX_HISTORY) { messageHistory.shift(); } connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // Corregido: limpieza al cerrar ws.on('close', () => { connections.delete(userId); }); ws.on('error', () => { connections.delete(userId); }); }); app.listen(3000);

Paso 4: Verificar la Corrección

Ejecuta la misma prueba de carga. La memoria ahora debería estabilizarse:

[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

¡Victoria!


Estrategias de Producción

1. Límites de Memoria y Alertas

Establece límites de memoria explícitos para prevenir crashes descontrolados:

node --max-old-space-size=512 server.js # límite de 512MB

Implementa alertas:

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

2. Reinicios Gráciles

Cuando la memoria sube demasiado, reinicia de forma grácil:

const RESTART_THRESHOLD = 500 * 1024 * 1024; setInterval(() => { if (process.memoryUsage().heapUsed > RESTART_THRESHOLD) { console.log('Umbral de memoria excedido, iniciando shutdown grácil'); // Dejar de aceptar nuevas conexiones server.close(() => { // Permitir que las peticiones existentes terminen setTimeout(() => { process.exit(0); // PM2 o orquestador de contenedores reiniciará }, 5000); }); } }, 60000);

3. Endpoints de Health Check

Expón métricas de memoria para monitoreo:

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 personalizadas connections: connections.size, cacheSize: cache.size, }); });

4. Heap Dump Bajo Demanda

Habilita heap dumps en producción para análisis post-mortem:

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

Prevención: Escribiendo Código Resistente a Fugas

Regla 1: Siempre Limpiar Event Listeners

// Usa AbortController para limpieza fácil const controller = new AbortController(); element.addEventListener('click', handler, { signal: controller.signal }); // Después: elimina todos los listeners añadidos con este controller controller.abort();

Regla 2: Limitar Todas las Colecciones

// En lugar de arrays ilimitados const items = []; items.push(newItem); // Usa colecciones limitadas const MAX_ITEMS = 10000; if (items.length >= MAX_ITEMS) { items.shift(); } items.push(newItem);

Regla 3: Usar WeakMap y WeakSet para Metadata

Cuando adjuntas metadata a objetos:

// ❌ Crea referencias fuertes, previene GC const metadata = new Map(); metadata.set(someObject, { extra: 'data' }); // ✅ Referencias débiles, no previene GC const metadata = new WeakMap(); metadata.set(someObject, { extra: 'data' }); // Cuando someObject ya no es referenciado en otro lugar, // la entrada de metadata se elimina automáticamente

Regla 4: Implementar Patrones de Dispose

class ResourceManager { #resources = []; #disposed = false; acquire(resource) { if (this.#disposed) { throw new Error('Manager está 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; // Limpiar array } }

Conclusión: El Cambio de Mentalidad

Depurar fugas de memoria requiere un cambio en cómo piensas sobre tu código. En lugar de solo preguntar "¿esto funciona?", necesitas preguntar:

  1. ¿A dónde van estos datos? Toda asignación debería tener un ciclo de vida claro.
  2. ¿Cuándo se limpia esto? Todo add debería tener su correspondiente remove.
  3. ¿Cuál es el límite superior? Toda colección debería tener un tamaño máximo.
  4. ¿Qué mantiene referencias a esto? Entiende tu grafo de referencias.

Las fugas de memoria no son misteriosas. Son solo objetos retenidos más tiempo del necesario. Con las herramientas y patrones de esta guía, puedes cazar sistemáticamente cualquier fuga y construir aplicaciones que corran de forma confiable durante meses sin reiniciar.

La próxima vez que la memoria de tu servidor empiece a subir, sabrás exactamente dónde buscar—y exactamente cómo solucionarlo.


Feliz depuración. Que tus heaps permanezcan pequeños y tu garbage collector ocioso.

nodejsmemory-leakdebuggingperformancejavascriptbackenddevops

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit