Saltar al contenido

Fugas de memoria en Node.js: detección y prevención

Closures, listeners de eventos y timers sin limpiar son las fugas de memoria más comunes en Node.js: aprende a detectarlas antes de que tumben producción.

3 min de lectura
Gráfico de memoria heap de Node.js que muestra un aumento gradual provocado por una fuga de memoria

Las fugas de memoria en Node.js no provocan una caída inmediata. Son lentas: el uso de memoria va subiendo durante horas o incluso días, hasta que el proceso se queda sin espacio en el heap y muere con un críptico error OOM. La causa suele ser algo mundano: un event listener que nunca se elimina, un closure que retiene una referencia más tiempo del esperado, o un caché que crece sin límite.

Cómo funciona la memoria en Node.js

V8 (el motor de JavaScript de Node) utiliza un recolector de basura generacional. Los objetos en el «new space» se recolectan rápidamente. Los objetos que sobreviven a varios ciclos de GC se promueven al «old space», que se recolecta con menor frecuencia y a un costo mayor.

Una fuga de memoria ocurre cuando hay objetos en old space que ya no se necesitan pero que siguen teniendo referencias activas: el recolector de basura no puede liberarlos.

tstypescript
// ❌ Global array that grows without bounds
const requestLog: object[] = [];
 
app.use((req, res, next) => {
  requestLog.push({
    url: req.url,
    headers: req.headers,
    timestamp: Date.now(),
    body: req.body, // Large objects accumulate
  });
  next();
});
// After 1M requests: requestLog holds gigabytes of data

Los cinco patrones de fuga más comunes

1. Event listeners que nunca se eliminan

tstypescript
// ❌ Adding a listener on every request — never removed
app.get("/api/stream", (req, res) => {
  const handler = (data: Buffer) => {
    res.write(data);
  };
 
  dataSource.on("data", handler);
  // If the client disconnects, the handler is never removed
  // The closure holds a reference to `res`, preventing GC
});
 
// ✅ Clean up on close
app.get("/api/stream", (req, res) => {
  const handler = (data: Buffer) => {
    res.write(data);
  };
 
  dataSource.on("data", handler);
 
  req.on("close", () => {
    dataSource.removeListener("data", handler);
  });
});

2. Closures que retienen referencias grandes

tstypescript
// ❌ Closure captures the entire large object
function processLargeDataset(data: Buffer) {
  const processed = expensiveTransform(data);
 
  return function getResult() {
    // This closure holds a reference to `data` (the entire Buffer)
    // even though it only needs `processed`
    return processed;
  };
}
 
// ✅ Let the large reference go out of scope
function processLargeDataset(data: Buffer) {
  const processed = expensiveTransform(data);
  // `data` reference is not captured by the returned function
  return function getResult() {
    return processed;
  };
}

3. Cachés sin límite

tstypescript
// ❌ Map that only grows — no eviction policy
const cache = new Map<string, unknown>();
 
function getCached(key: string, fetcher: () => Promise<unknown>) {
  if (cache.has(key)) return cache.get(key);
  const value = fetcher();
  cache.set(key, value);
  return value;
}
 
// ✅ LRU cache with a maximum size
import { LRUCache } from "lru-cache";
 
const cache = new LRUCache<string, unknown>({
  max: 500,          // Maximum 500 entries
  ttl: 1000 * 60 * 5, // 5-minute TTL
});

4. Timers e intervalos que nunca se limpian

tstypescript
// ❌ setInterval inside a handler — never cleared
class DataPoller {
  start() {
    setInterval(async () => {
      const data = await fetchLatestData();
      this.processData(data);
    }, 5000);
  }
  // If this object is "discarded" but the interval still runs,
  // it prevents the entire object from being garbage collected
}
 
// ✅ Store interval ID and clear on cleanup
class DataPoller {
  private intervalId: NodeJS.Timeout | null = null;
 
  start() {
    this.intervalId = setInterval(async () => {
      const data = await fetchLatestData();
      this.processData(data);
    }, 5000);
  }
 
  stop() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }
}

5. Promises que nunca se resuelven

tstypescript
// ❌ Promises that hang forever hold their closure in memory
function waitForEvent(emitter: EventEmitter, event: string) {
  return new Promise((resolve) => {
    emitter.once(event, resolve);
    // If the event never fires, this Promise (and its closure) lives forever
  });
}
 
// ✅ Add a timeout
function waitForEvent(emitter: EventEmitter, event: string, timeoutMs = 30000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      emitter.removeListener(event, onEvent);
      reject(new Error(`Timeout waiting for ${event}`));
    }, timeoutMs);
 
    function onEvent(data: unknown) {
      clearTimeout(timer);
      resolve(data);
    }
 
    emitter.once(event, onEvent);
  });
}

Detección: heap snapshots

El inspector integrado de V8 permite tomar heap snapshots y compararlos a lo largo del tiempo.

tstypescript
// Expose a diagnostic endpoint (behind auth!)
import v8 from "v8";
import fs from "fs";
 
app.get("/debug/heap-snapshot", requireInternalAuth, (req, res) => {
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  const snapshotStream = v8.writeHeapSnapshot(filename);
 
  res.json({ file: snapshotStream, message: "Heap snapshot written" });
});
shbash
# Take two snapshots 10 minutes apart
# Open both in Chrome DevTools (Memory tab)
# Compare: objects that exist in snapshot 2 but not snapshot 1 are potential leaks

Monitoreo del uso del heap

Registra el uso del heap a lo largo del tiempo. Un proceso saludable muestra un patrón de dientes de sierra (la memoria sube, se ejecuta el GC, la memoria baja). Una fuga se manifiesta como una línea base que sube de forma constante.

tstypescript
// Log memory metrics every 30 seconds
setInterval(() => {
  const usage = process.memoryUsage();
  console.log({
    heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
    heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
    rss: Math.round(usage.rss / 1024 / 1024),
    external: Math.round(usage.external / 1024 / 1024),
  });
}, 30000);

Puntos clave

  1. Los event listeners son la fuente de fugas #1 — elimínalos siempre cuando el consumidor se desconecte
  2. Los closures capturan más de lo que crees — presta atención a qué referencias quedan retenidas
  3. Los cachés necesitan políticas de desalojo — usa LRU con tamaño máximo y TTL, nunca Maps sin límite
  4. Limpia todos los timers — guarda los IDs de intervalo/timeout y límpialos al finalizar
  5. Los heap snapshots son tu herramienta de diagnóstico — compara dos snapshots para encontrar objetos retenidos
  6. Monitorea las tendencias de uso del heap — una línea base que sube entre ciclos de GC indica una fuga
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX