Los memory leaks en JavaScript son más frecuentes de lo que parece y pueden degradar el rendimiento de una aplicación de forma silenciosa y progresiva. A diferencia de lenguajes con gestión manual de memoria, en JavaScript los leaks ocurren cuando el garbage collector no puede liberar objetos porque siguen siendo referenciados, aunque ya no sean necesarios.
Event listeners sin eliminar
El patrón más común de memory leak en el navegador: añadir listeners que nunca se eliminan, especialmente cuando el elemento DOM se recrea:
// LEAK cada vez que se monta el componente, añade otro listener
function montarComponente() {
const boton = document.getElementById('enviar');
boton.addEventListener('click', function handleClick() {
console.log('click');
// La función keepAlive mantiene viva la referencia al scope
procesarDatos(cacheLocal);
});
}
// Si montarComponente() se llama varias veces (SPA con navegación),
// el botón acumula N listeners sin que los anteriores se eliminen.
// CORRECTO guardar referencia y eliminar al desmontar
function montarComponente() {
const boton = document.getElementById('enviar');
function handleClick() {
console.log('click');
procesarDatos(cacheLocal);
}
boton.addEventListener('click', handleClick);
// Devolver función de limpieza
return function desmontar() {
boton.removeEventListener('click', handleClick);
};
}
const desmontar = montarComponente();
// Al navegar a otra página:
desmontar();
Closures que retienen el scope
Las closures son útiles pero pueden retener involuntariamente objetos grandes en memoria si capturan variables del scope externo que ya no se usan:
// LEAK la closure retiene un objeto grande aunque solo use una propiedad
function crearHandler() {
const datosGrandes = new Array(1_000_000).fill('datos'); // 8MB aprox.
const idImportante = datosGrandes[0];
// Esta closure cierra sobre datosGrandes completo, no solo sobre idImportante
return function handler(evento) {
console.log(idImportante, evento.type);
// datosGrandes sigue en memoria mientras handler exista
};
}
// CORRECTO extraer solo lo necesario antes de crear la closure
function crearHandlerBien() {
const datosGrandes = new Array(1_000_000).fill('datos');
const idImportante = datosGrandes[0];
// datosGrandes puede ser recogido por GC después de esta línea
return function handler(evento) {
console.log(idImportante, evento.type);
};
}
setInterval sin clearInterval
// LEAK el setInterval mantiene viva la referencia a actualizarUI y a sus dependencias
function iniciarPoll() {
const datos = { cache: new Map(), historial: [] };
setInterval(() => {
actualizarUI(datos); // datos nunca puede ser recolectado
}, 1000);
}
// CORRECTO guardar el ID y limpiar
class PollingServicio {
#intervalId = null;
#datos = { cache: new Map(), historial: [] };
iniciar() {
if (this.#intervalId) return; // Evitar duplicados
this.#intervalId = setInterval(() => {
actualizarUI(this.#datos);
}, 1000);
}
detener() {
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
}
}
Variables globales accidentales
// En modo no-strict, asignar a una variable no declarada crea una global
function procesarPedido(datos) {
resultado = transformar(datos); // Sin let/const/var ? window.resultado
// resultado nunca se libera porque está en el objeto global
}
// CORRECTO siempre declarar las variables
function procesarPedido(datos) {
const resultado = transformar(datos);
return resultado;
}
// Usar "use strict" o módulos ES (que son siempre strict)
'use strict';
function procesarPedido(datos) {
resultado = transformar(datos); // ReferenceError detectado inmediatamente
}
Referencias en estructuras de datos olvidadas
// LEAK cache que crece sin límite
const cache = new Map();
function obtenerDatos(id) {
if (cache.has(id)) return cache.get(id);
const datos = fetchSincrono(id);
cache.set(id, datos); // Nunca se elimina ? memory leak
return datos;
}
// MEJOR usar WeakMap cuando las claves son objetos
const cachePorObjeto = new WeakMap();
// Las entradas se eliminan automáticamente cuando la clave (objeto) no tiene más referencias
// O implementar una cache con límite de tamaño (LRU)
class CacheLRU {
#capacidad;
#mapa = new Map();
constructor(capacidad = 100) {
this.#capacidad = capacidad;
}
get(clave) {
if (!this.#mapa.has(clave)) return undefined;
const valor = this.#mapa.get(clave);
// Mover al final (más reciente)
this.#mapa.delete(clave);
this.#mapa.set(clave, valor);
return valor;
}
set(clave, valor) {
if (this.#mapa.has(clave)) this.#mapa.delete(clave);
else if (this.#mapa.size >= this.#capacidad) {
// Eliminar el más antiguo (primer elemento)
this.#mapa.delete(this.#mapa.keys().next().value);
}
this.#mapa.set(clave, valor);
}
}
Detectar leaks con DevTools Memory
Chrome DevTools tiene tres herramientas de memoria clave para cazar leaks:
// 1. HEAP SNAPSHOT // DevTools ? Memory ? "Take heap snapshot" // Realiza la acción sospechosa (navegar, abrir/cerrar componentes) // Toma otro snapshot ? "Comparison" view // Buscar objetos que crecen entre snapshots // 2. ALLOCATION INSTRUMENTATION ON TIMELINE // DevTools ? Memory ? "Record allocation timeline" // Ejecutar la acción varias veces // Los bloques azules que persisten tras GC son leaks // 3. ALLOCATION SAMPLING (bajo coste de CPU) // Para profiling largo sin impacto significativo // Forzar GC antes de snapshot para resultados más limpios: // DevTools ? Memory ? ícono de papelera (Collect Garbage)
WeakRef y FinalizationRegistry (ES2021)
// WeakRef: referencia débil que no impide la recolección de basura
const ref = new WeakRef(objetoGrande);
// Más tarde, el objeto puede haber sido recogido:
const obj = ref.deref();
if (obj) {
// El objeto sigue vivo usarlo
procesarObjeto(obj);
} else {
// El objeto fue recolectado recrarlo o manejar el caso
}
// FinalizationRegistry: ejecutar callback cuando un objeto es recolectado
const registro = new FinalizationRegistry((clave) => {
console.log(`Objeto con clave "${clave}" fue recolectado`);
cache.delete(clave); // Limpiar referencias en la cache
});
const objeto = crearObjetoGrande();
registro.register(objeto, 'clave-objeto');
// Cuando objeto sea recolectado, se ejecuta el callback
La clave para evitar memory leaks en JavaScript es desarrollar el hábito de limpiar siempre lo que se crea: eliminar listeners cuando se dejan de necesitar, limpiar timers e intervalos, evitar caches sin límite y no guardar referencias a objetos grandes en closures de larga vida. Y ante la duda, las herramientas de memoria de DevTools permiten verificarlo con datos reales.
