Una pantalla se abre y se cierra sin problemas, pero después de repetirlo muchas veces la pestaña empieza a consumir más memoria. En el servidor ocurre algo parecido: cada petición termina, aunque el proceso conserva datos de peticiones anteriores. No basta con que una función haya terminado para que todo lo que utilizó desaparezca.
JavaScript administra memoria automáticamente, pero el recolector no conoce la intención de tu aplicación. Si una estructura todavía conserva una referencia a un objeto, ese objeto puede seguir vivo aunque tu funcionalidad ya no lo necesite.
Vamos a identificar esas referencias con ejemplos pequeños y a comprobar una retención real en Node.js. Después seguiremos una ruta de investigación en Chrome DevTools. El objetivo no es perseguir cualquier aumento de memoria, sino encontrar objetos cuya vida útil terminó y que siguen retenidos sin motivo.
Memoria automática no significa memoria ilimitada
Crear un objeto requiere memoria; usarlo mantiene sus datos disponibles; cuando deja de ser alcanzable, el motor puede recuperar ese espacio. El garbage collector, o recolector de basura, automatiza esa última parte. El heap es la región de memoria administrada por el motor donde se alojan muchos de esos objetos.
Conceptualmente, el recolector parte de un conjunto de raíces, como referencias mantenidas por el runtime, objetos globales y contextos activos. Recorre los enlaces y conserva lo que sigue siendo alcanzable. No necesita que un objeto tenga cero referencias: dos objetos que se apuntan entre sí pueden recolectarse si todo el grupo quedó desconectado de las raíces. MDN: gestión de memoria.
Los motores aplican optimizaciones para repartir y reducir ese trabajo. V8, utilizado por Chrome y Node.js, describe técnicas generacionales, incrementales y concurrentes en su explicación del recolector Orinoco. Son detalles de implementación: no debes depender de que un objeto se recolecte inmediatamente después de dejar de usarlo.
Una fuga de memoria aparece cuando la aplicación conserva memoria que ya no necesita y no la libera conforme a su ciclo de vida previsto. En JavaScript suele haber un camino de referencias que el recolector está respetando correctamente.
Este es el caso de un listener registrado en window que conserva un panel retirado del documento:
No se pudo mostrar el diagrama. Puedes consultar su definición.
---
config:
flowchart:
wrappingWidth: 220
---
flowchart TB
R["`**Referencia persistente**
window`"]
L["`**Listener registrado**
Callback de resize`"]
C["`**Entorno del callback**
Referencia a panel`"]
P["`**Objeto retenido**
Panel retirado del DOM`"]
R --> L --> C --> P
Quitar el panel del DOM rompe su relación con el documento, pero no el camino que pasa por el listener. Para decidir si es una fuga, falta una pregunta funcional: ¿el panel debería seguir disponible después de cerrarlo?
Referencias que sobreviven a su propósito
Una colección de larga duración es un lugar frecuente donde buscar. Este fragmento conserva el objeto aunque la variable local deje de referenciarlo:
const pendientes = new Map();
let solicitud = { id: 'demo-1', datos: new Array(1000).fill(0) };
pendientes.set(solicitud.id, solicitud);
solicitud = null;
console.log(pendientes.size); // 1: el Map sigue conservando el objeto.
pendientes.delete('demo-1'); // Al terminar la operación, retiramos su referencia.Asignar null cambia una referencia, no todas las referencias al objeto. La corrección habitual consiste en retirar la entrada cuando termina la operación, incluidos los caminos de error. No borres una colección entera si todavía contiene trabajo válido.
En frontend, revisa almacenes globales, listas de componentes y selecciones antiguas. En Node.js, revisa mapas por usuario, acumuladores de peticiones, colas pendientes y suscripciones que se crean por cada conexión. El problema se amplifica cuando el proceso permanece activo durante mucho tiempo.
Event listeners: el problema es quién vive más tiempo
Un listener no es una fuga por definición. Un botón y su callback pueden dejar de ser alcanzables juntos. El riesgo aparece cuando el emisor dura más que la funcionalidad: window, document o un bus global pueden conservar callbacks de pantallas que ya se cerraron.
Guarda este programa completo como listeners.html y ábrelo en una pestaña de Chrome. No requiere dependencias ni hace peticiones de red:
<!doctype html>
<html lang="es">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laboratorio de listeners</title>
<button id="sin-limpieza">Crear y retirar 10 paneles sin limpiar</button>
<button id="con-limpieza">Crear y retirar 10 paneles con limpieza</button>
<button id="comprobar">Disparar resize</button>
<p id="estado" role="status">Callbacks ejecutados: 0</p>
<script>
let ejecuciones = 0;
function crearYRetirar(limpiar) {
const panel = document.createElement('section');
panel.textContent = 'Panel temporal';
document.body.append(panel);
function alRedimensionar() {
panel.dataset.ancho = String(window.innerWidth);
ejecuciones++;
}
window.addEventListener('resize', alRedimensionar);
panel.remove();
if (limpiar) {
window.removeEventListener('resize', alRedimensionar);
}
}
function repetir(limpiar) {
for (let i = 0; i < 10; i++) {
crearYRetirar(limpiar);
}
}
document.querySelector('#sin-limpieza').onclick = () => repetir(false);
document.querySelector('#con-limpieza').onclick = () => repetir(true);
document.querySelector('#comprobar').onclick = () => {
ejecuciones = 0;
window.dispatchEvent(new Event('resize'));
document.querySelector('#estado').textContent =
`Callbacks ejecutados: ${ejecuciones}`;
};
</script>
</html>Primero pulsa Crear y retirar 10 paneles sin limpiar y después Disparar resize. El contador muestra 10: siguen ejecutándose callbacks de paneles que ya no aparecen en el documento.
Recarga la página, pulsa Crear y retirar 10 paneles con limpieza y vuelve a disparar el evento. Ahora muestra 0. La recarga es importante: la variante correcta no elimina listeners que dejó registrados una ejecución anterior de la variante defectuosa.
Para retirar un listener necesitas el mismo tipo de evento, la misma función y el mismo valor de capture. Crear otra función anónima con un cuerpo idéntico no coincide con la anterior. También puedes agrupar listeners de una vista mediante un AbortController, pasar su signal al registrarlos y llamar a abort() al desmontar. MDN: removeEventListener.
En una aplicación real, vincula esa limpieza al cierre de la vista o al desmontaje del componente. once: true sirve cuando necesitas una sola ejecución, pero no resuelve un listener cuyo evento nunca llega antes del cierre.
Timers e intervalos: programar también crea una responsabilidad
Un intervalo pendiente puede conservar su callback y los datos que este necesita. Si cada apertura de una pantalla crea otro intervalo y no lo cancela, acumulas trabajo y referencias.
Este ejemplo funciona en Node.js o en un navegador. Guarda la función de limpieza y ejecútala al terminar la funcionalidad:
function iniciarContador(estado) {
const id = setInterval(() => {
estado.ticks++;
}, 1000);
return () => clearInterval(id);
}
const estado = { ticks: 0 };
let detener = iniciarContador(estado);
// Simulamos que la funcionalidad acaba antes del primer tick.
detener();
detener = null;Aquí el callback deja de estar programado. Si además guardas la función detener en un registro global, retírala de ese registro cuando ya no sea necesaria: las propias estructuras de limpieza también tienen un ciclo de vida.
Un setTimeout de una sola ejecución también puede retener datos mientras espera. Cancélalo si el trabajo dejó de tener sentido. En Node.js, unref() no cancela un timer: permite que ese timer no mantenga por sí solo el proceso en ejecución. No sustituye a clearTimeout o clearInterval. Node.js: timers.
Los callbacks de timers y su ejecución se relacionan con el event loop de JavaScript, pero cancelar un timer no interrumpe retroactivamente una operación asíncrona que su callback ya inició.
Closures: conservar contexto es útil; conservar demasiado puede costar
Un closure permite que una función acceda a su entorno léxico. Esa capacidad es parte normal del lenguaje, no un defecto. El problema surge cuando una función de larga duración necesita muy poco de un objeto grande, pero mantiene acceso al objeto completo. MDN: closures.
Compara estas dos interfaces:
function crearEtiquetaDesdeUsuario(usuario) {
return () => usuario.nombre;
}
function crearEtiquetaDesdeNombre(nombre) {
return () => nombre;
}
let usuario = {
nombre: 'Ada',
historial: new Array(10000).fill('evento ficticio'),
};
let etiquetaCompleta = crearEtiquetaDesdeUsuario(usuario);
const etiquetaReducida = crearEtiquetaDesdeNombre(usuario.nombre);
usuario = null;
console.log(etiquetaCompleta()); // Ada: todavía accede al objeto original.
console.log(etiquetaReducida()); // Ada: recibió solamente el nombre.
etiquetaCompleta = null;La segunda función no recibe el historial. A cambio, captura el nombre que le pasaste: no reflejará cambios posteriores en el objeto original. Reducir referencias tiene que respetar el comportamiento que necesitas; no cambies una vista de datos vivos por una copia desactualizada sin decidirlo.
No asumas que cada closure conserva todas las variables visibles en el código fuente: los motores pueden optimizar su representación. Comprueba la cadena real de retención. La guía de closures del blog explica el fundamento; aquí la decisión es cuánto contexto necesita una función y hasta cuándo.
Cachés: guardar resultados sin política de salida
Una caché puede estar funcionando exactamente como la escribiste y aun así consumir memoria sin límite. Si la clave incluye un identificador distinto por petición, un Map que nunca expulsa entradas seguirá creciendo con la carga.
Este fragmento limita a 100 las entradas con una política sencilla: expulsar la más antigua por orden de inserción, FIFO. Es una decisión didáctica, no una caché universal:
const cache = new Map();
const MAX_ENTRADAS = 100;
function guardar(clave, valor) {
cache.set(clave, valor);
if (cache.size > MAX_ENTRADAS) {
const primeraClave = cache.keys().next().value;
cache.delete(primeraClave);
}
}
for (let id = 0; id < 1000; id++) {
guardar(id, { resultado: id });
}
console.log(cache.size); // 100
console.log(cache.has(0)); // false
console.log(cache.has(999)); // trueEste límite no controla bytes: 100 objetos enormes pueden seguir siendo demasiados. En producción, decide capacidad, tamaño máximo de valor, caducidad y política de expulsión según el uso. Un TTL que solo elimina al consultar una clave no libera necesariamente las entradas que nadie vuelve a consultar.
WeakMap es útil para asociar metadatos a objetos sin que la colección mantenga vivas sus claves por sí sola. No equivale a una caché general de claves string ni ofrece enumeración y límites de capacidad como un Map. MDN: WeakMap.
Tampoco uses WeakRef o finalizadores como garantía de limpieza puntual. El momento de recolección no es determinista; para cancelar trabajo y cerrar recursos, define operaciones explícitas. MDN: límites de WeakRef.
Investigar con Chrome DevTools: del crecimiento a su causa
Comienza con una operación repetible: abrir y cerrar la misma pantalla, entrar y salir de una ruta o conectar y desconectar una vista. Compara estados equivalentes después de un calentamiento inicial; cargar módulos o llenar una caché por primera vez puede aumentar la memoria sin que haya una fuga.
DevTools distingue fugas, uso excesivo de memoria y recolecciones frecuentes. Un patrón de subida y bajada puede ser el GC recuperando asignaciones temporales; una base que crece tras repetir la misma operación merece investigación. Chrome: diagnosticar problemas de memoria.
Con listeners.html, sigue este procedimiento:
- Recarga la página y abre DevTools → Memory → Heap snapshot. Toma una captura inicial.
- Ejecuta una vez la variante sin limpieza y toma otra captura.
- En Comparison, compara con la primera. Busca
Detachedpara explorar nodos retirados del DOM. - Selecciona un panel y revisa Retainers: interesa el camino que lo mantiene vivo, no solo dónde fue creado.
- Recarga y repite el mismo ciclo usando la variante con limpieza. Comprueba que desaparece la acumulación atribuible a esos paneles.
Una captura es una fotografía del heap. Shallow size mide el tamaño del objeto en sí; retained size considera memoria que podría liberarse si desapareciera ese retenedor y los objetos que dependen exclusivamente de él. Una función pequeña puede sostener un grafo grande. No sumes indiscriminadamente tamaños retenidos que pueden solaparse. Chrome: heap snapshots, Comparison y Retainers.
Evita imprimir los objetos investigados en la consola o conservarlos en variables de depuración: las herramientas pueden introducir referencias adicionales. El contador del laboratorio imprime solamente un número. Un nodo desconectado tampoco demuestra por sí solo un defecto: una caché temporal podría conservarlo deliberadamente; necesitas contrastarlo con su vida útil esperada.
Para localizar dónde se asigna memoria, usa los perfiles de asignación del panel Memory. Para explicar por qué sigue viva, inspecciona retenedores. Son preguntas complementarias.
Laboratorio Node.js: el GC no puede vaciar tu Map
Usaremos Node.js 22 o superior, sin dependencias, en una carpeta nueva. El programa crea cuatro lotes pequeños y termina; no es una prueba de carga sobre un servicio real.
Guarda este archivo como retencion.mjs:
import { memoryUsage } from 'node:process';
import { writeHeapSnapshot } from 'node:v8';
import { setImmediate } from 'node:timers/promises';
if (typeof global.gc !== 'function') {
throw new Error('Ejecuta: node --expose-gc retencion.mjs');
}
class SesionDemo {
constructor(id) {
this.id = id;
this.datos = new Array(2000).fill(id);
}
}
const sesiones = new Map();
const muestras = [];
const conSnapshots = process.argv.includes('--snapshots');
function medir(etapa) {
global.gc(); // Solo para comparar fases en este laboratorio.
const { heapUsed, rss } = memoryUsage();
muestras.push({
etapa,
sesiones: sesiones.size,
heapMiB: Number((heapUsed / 1024 ** 2).toFixed(2)),
rssMiB: Number((rss / 1024 ** 2).toFixed(2)),
});
}
medir('inicio');
if (conSnapshots) {
writeHeapSnapshot('01-inicio.heapsnapshot');
}
for (let lote = 0; lote < 4; lote++) {
for (let i = 0; i < 250; i++) {
const id = lote * 250 + i;
sesiones.set(id, new SesionDemo(id));
}
await setImmediate();
medir(`lote ${lote + 1}`);
}
if (conSnapshots) {
writeHeapSnapshot('02-retenidas.heapsnapshot');
}
sesiones.clear();
await setImmediate();
medir('después de clear');
if (conSnapshots) {
writeHeapSnapshot('03-liberadas.heapsnapshot');
}
console.table(muestras);Ejecuta primero:
node --expose-gc retencion.mjsCada lote deja más sesiones alcanzables desde sesiones, incluso después de solicitar GC. clear() elimina las entradas al terminar el trabajo ficticio. En una aplicación real, la corrección sería eliminar cada sesión al finalizar o expirar, según su contrato; no vaciar indiscriminadamente sesiones válidas.
--expose-gc habilita una herramienta de diagnóstico de Node/V8. No forma parte de la lógica que debes añadir a producción para «arreglar» una fuga: forzar recolección no elimina referencias necesarias para el programa, aunque sean innecesarias para tu negocio.
Capturas y resultados observados
Para obtener archivos que puedas abrir en Memory → Load de Chrome DevTools, ejecuta:
node --expose-gc retencion.mjs --snapshotsLas capturas se guardan en la carpeta actual; vuelve a ejecutarlo en otra carpeta si quieres conservar una serie anterior. Filtra por SesionDemo y compara 02-retenidas con 01-inicio, y después 03-liberadas con 02-retenidas. Busca también el Map y su camino de retención.
En una ejecución local en macOS, con snapshots activados, se obtuvo lo siguiente:
| Fase | Entradas del Map | heapUsed, MiB | RSS, MiB |
|---|---|---|---|
| Inicio | 0 | 3,40 | 47,61 |
| Cuarto lote | 1.000 | 18,40 | 93,19 |
| Después de clear y GC | 0 | 3,01 | 101,34 |
Además, al inspeccionar los archivos generados, las instancias de SesionDemo fueron 0 → 1.000 → 0. Estos resultados son observados, no valores que debas obtener exactamente: la versión del motor, el sistema y la creación de snapshots afectan al consumo. Una MiB equivale a 1.048.576 bytes.
La caída del heap y la desaparición de las instancias respaldan la explicación de este laboratorio. El RSS más alto al final demuestra por qué no puedes exigir que toda la memoria del proceso vuelva inmediatamente al valor inicial. La medición también incluye el efecto de las herramientas que estamos usando.
Elegir la métrica adecuada
process.memoryUsage() devuelve varias medidas en bytes:
heapUsed: heap de V8 utilizado.heapTotal: memoria del heap que V8 tiene asignada.rss: memoria residente del proceso, incluyendo código y memoria nativa.external: memoria de objetos nativos vinculados a objetos JavaScript.arrayBuffers: memoria deArrayBuffer,SharedArrayBufferyBufferde Node; ya forma parte deexternal.
Si retienes Buffer grandes, mirar solo heapUsed puede ocultar buena parte del crecimiento. Un RSS que no baja tampoco prueba por sí solo una fuga del heap: intervienen memoria nativa y decisiones del asignador. Correlaciona consumo, volumen de trabajo y tamaño de las colecciones; puedes incorporar esas señales a tu observabilidad con logs, métricas y trazas.
Para observar actividad del recolector, ejecuta node --expose-gc --trace-gc retencion.mjs. Los mensajes muestran recolecciones y ocupación antes y después; no identifican por sí solos el objeto que mantiene viva una colección. En este ejemplo verás también las recolecciones que solicitamos expresamente. Node.js: trazas de GC.
Diagnosticar sin convertir el diagnóstico en otro incidente
Una captura del heap puede detener el trabajo del hilo principal y necesitar aproximadamente el doble de memoria del heap. En producción podría provocar una caída por falta de memoria. Node recomienda tomarla solo donde la posible caída de esa instancia no comprometa la disponibilidad. Node.js: precauciones con heap snapshots.
Las capturas también pueden contener datos de usuarios, tokens o cadenas de conexión. Trátalas como material sensible, limita el acceso y no las subas al repositorio ni a servicios públicos. El laboratorio utiliza datos ficticios y no necesita habilitar un puerto de depuración.
Para limpiarlo, cierra la pestaña del ejemplo y elimina los archivos de la carpeta de laboratorio, incluidas las capturas. No borres directorios de una aplicación real para reproducir estos pasos.
Cada recurso necesita un dueño y un final
Al revisar una funcionalidad, busca parejas: registrar y retirar; iniciar y cancelar; insertar y eliminar. Decide qué componente es responsable de esa segunda operación y pruébala también cuando ocurre un error o el usuario abandona la pantalla.
Una corrección convincente conserva el comportamiento esperado y deja de acumular objetos después de repetir el mismo ciclo. Aumentar el límite de memoria o reiniciar el proceso puede dar margen operativo, pero no explica ni elimina una referencia retenida. La pregunta que guía la investigación es concreta: ¿qué camino mantiene vivo este objeto y por qué sigue existiendo después de terminar su trabajo?
Referencias
- MDN: gestión de memoria en JavaScript.
- V8: explicación del garbage collector Orinoco.
- MDN: removeEventListener y closures.
- MDN: WeakMap y WeakRef.
- Chrome DevTools: problemas de memoria y heap snapshots.
- Node.js: timers, process.memoryUsage y writeHeapSnapshot.
- Node.js: capturas del heap y trazas del recolector.
