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.

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.
// ❌ 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 dataLos cinco patrones de fuga más comunes
1. Event listeners que nunca se eliminan
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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.
// 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" });
});# 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 leaksMonitoreo 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.
// 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
- Los event listeners son la fuente de fugas #1 — elimínalos siempre cuando el consumidor se desconecte
- Los closures capturan más de lo que crees — presta atención a qué referencias quedan retenidas
- Los cachés necesitan políticas de desalojo — usa LRU con tamaño máximo y TTL, nunca Maps sin límite
- Limpia todos los timers — guarda los IDs de intervalo/timeout y límpialos al finalizar
- Los heap snapshots son tu herramienta de diagnóstico — compara dos snapshots para encontrar objetos retenidos
- Monitorea las tendencias de uso del heap — una línea base que sube entre ciclos de GC indica una fuga


