wandres.dev
MEMORY II · Cazar una fuga

Una fuga de principio a fin: el caso completo

Un caso reproducible que puedes pegar en cualquier pestaña, con las tres fugas que contiene, la sesión de diagnóstico paso a paso y la verificación del arreglo.

⏱ 24 min

Todo lo anterior de este nivel son piezas. Esta lección las junta en una sesión completa sobre un caso que puedes ejecutar ahora mismo: una aplicación de veinte líneas con tres fugas distintas, tres firmas distintas y tres arreglos distintos. El objetivo no es que aprendas estas tres fugas concretas, sino que hagas una vez el recorrido entero —del síntoma a la línea de código, con la verificación al final— para que la próxima vez que lo veas en tu propio proyecto reconozcas el camino.

🎯 Al terminar esta lección sabrás
  • Ejecutar un caso de fuga reproducible y observar su síntoma.
  • Aplicar el procedimiento completo hasta identificar las tres causas.
  • Escribir el arreglo de cada una con el mecanismo adecuado.
  • Verificar que el arreglo funciona con un criterio binario.

El caso

Pega esto en la consola de cualquier página en blanco. Crea una interfaz mínima con un botón que abre y cierra un panel de detalle, y un ciclo automatizado.

// ---- APLICACION CON FUGAS. Pegala tal cual en una pestana en blanco ----
(() => {
  document.body.innerHTML = `
    <h1>Casos</h1>
    <button id="abrir">Abrir detalle</button>
    <button id="cerrar">Cerrar detalle</button>
    <div id="host"></div>
    <p id="estado">Paneles abiertos en total: 0</p>`;

  const host = document.getElementById('host');
  const estado = document.getElementById('estado');
  let abiertos = 0;

  // FUGA 1: registro global que nunca se poda
  const historialDePaneles = [];

  class PanelDeDetalle {
    constructor(id) {
      this.id = id;
      // Carga simulada: la respuesta de una API con muchos datos
      this.datos = Array.from({ length: 5000 }, (_, i) => ({
        fila: i, texto: 'valor de la fila ' + i + ' del panel ' + id
      }));

      this.raiz = document.createElement('div');
      this.raiz.className = 'panel';
      this.raiz.innerHTML =
        '<h2>Detalle ' + id + '</h2><ul>' +
        this.datos.slice(0, 50).map(d => '<li>' + d.texto + '</li>').join('') +
        '</ul>';
      host.append(this.raiz);

      // FUGA 2: escucha en document que captura this y nunca se quita
      document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') this.cerrar();
      });

      // FUGA 3: intervalo que nunca se cancela
      this.intervalo = setInterval(() => {
        this.raiz.dataset.tick = String(Date.now());
      }, 500);

      historialDePaneles.push(this);   // la fuga 1 en accion
      abiertos++;
      estado.textContent = 'Paneles abiertos en total: ' + abiertos;
    }

    cerrar() {
      this.raiz.remove();              // sale del documento... y nada mas
    }
  }

  let siguienteId = 1;
  let actual = null;

  document.getElementById('abrir').onclick = () => {
    actual = new PanelDeDetalle(siguienteId++);
  };
  document.getElementById('cerrar').onclick = () => {
    actual?.cerrar();
    actual = null;
  };

  // Ciclo automatizado para las sesiones de memoria
  window.ciclo = async (vueltas = 10) => {
    for (let i = 0; i < vueltas; i++) {
      document.getElementById('abrir').click();
      await new Promise(r => setTimeout(r, 60));
      document.getElementById('cerrar').click();
      await new Promise(r => setTimeout(r, 60));
    }
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    console.log(vueltas, 'ciclos completados. Fuerza la recoleccion y toma la instantanea.');
  };

  window.__app = { historialDePaneles, PanelDeDetalle };
  console.log('Aplicacion lista. Ejecuta: await ciclo(10)');
})();

El síntoma es fácil de provocar: ejecuta await ciclo(30) y observa que el navegador consume cada vez más memoria, que el atributo de datos de paneles ya cerrados sigue actualizándose —hay treinta intervalos vivos— y que pulsar la tecla de escape ejecuta treinta manejadores en lugar de uno.

La sesión de diagnóstico

Paso 1: la expectativa

Antes de tocar nada, escribe la frase. “Después de abrir y cerrar un panel, no debería quedar ninguna instancia de PanelDeDetalle viva, ni ningún nodo del panel, ni ningún intervalo activo.”

Esa frase contiene tres afirmaciones comprobables, y cada una tiene su propia comprobación barata.

Paso 2: la comprobación de treinta segundos

Antes del panel de memoria, dos preguntas desde la consola:

// Comprobaciones rapidas antes de abrir ningun panel
await ciclo(10);

// A. Cuantas instancias vivas hay. En la consola de DevTools:
//    queryObjects(__app.PanelDeDetalle)
//    -> deberia mostrar 0 y muestra 10

// B. Cuantos escuchas de teclado hay en document:
//    getEventListeners(document).keydown?.length
//    -> deberia ser 0 o 1 y es 10

// C. Nodos separados alcanzables desde el ambito global
console.log('Historial acumulado:', __app.historialDePaneles.length);
console.log('Nodos separados en el historial:',
  __app.historialDePaneles.filter(p => !p.raiz.isConnected).length);

Con eso ya está confirmada la fuga y localizada la primera causa, sin haber tomado ninguna instantánea. Esta comprobación es la que hay que hacer siempre primero, porque en un caso real puede cerrar el diagnóstico en menos de un minuto.

Paso 3: las tres instantáneas

Para las causas que la comprobación rápida no revela, el procedimiento completo. Recarga, vuelve a pegar la aplicación, y:

Instantánea 1 con la aplicación recién cargada.

await ciclo(10) y instantánea 2.

await ciclo(10) otra vez y instantánea 3.

En la instantánea 3, filtra por los objetos asignados entre la 1 y la 2. Deberías ver:

Diez instancias de PanelDeDetalle. Número redondo, uno por ciclo. Diagnóstico inmediato.

Diez nodos separados de tipo división, cada uno con su subárbol de elementos de lista.

Objetos de contexto de función, correspondientes a los cierres de los manejadores.

Selecciona una instancia de PanelDeDetalle y lee su cadena de retenedores. Vas a encontrar tres caminos distintos, y ese hallazgo es la parte más instructiva del ejercicio: los tres tienen que cortarse para que el objeto se libere.

Camino Cómo se ve en la cadena Causa
Elemento de un array historialDePaneles como array retenedor El registro global
Contexto de una función Un nodo de contexto retenido por el registro de escuchas del navegador El escucha de teclado
Contexto de una función Un nodo de contexto retenido por el registro de temporizadores El intervalo
⚠️
Cuidado

Este es exactamente el motivo por el que hay que leer todos los caminos antes de escribir el arreglo. Si solo quitas el registro global y compruebas, la memoria no baja nada y la conclusión natural —equivocada— es que el registro global no era el problema. Con tres caminos, cortar uno no libera absolutamente nada. La verificación correcta en ese punto intermedio no es medir memoria sino volver a mirar la cadena y comprobar que ese camino ha desaparecido.

Paso 4: la línea de tiempo de asignaciones

Como comprobación complementaria, graba una línea de tiempo de asignaciones durante dos ciclos. Verás un patrón repetido de dos grupos de barras, y en cada grupo quedará una barra azul con la misma forma. Al seleccionarla, la pila de llamadas apunta al constructor de PanelDeDetalle, que es donde se crean las tres relaciones.

Es información redundante en este caso porque las tres fugas nacen en el mismo sitio, y esa redundancia es en sí misma un dato: cuando la pila de asignación y la cadena de retención convergen en el mismo lugar, el arreglo es local. Cuando divergen, el arreglo está en el punto de la retención.

El arreglo y su verificación

Las tres fugas tienen tres arreglos distintos, y conviene ver los tres porque son los tres mecanismos del nivel.

// ---- VERSION CORREGIDA ----
(() => {
  document.body.innerHTML = `
    <h1>Casos</h1>
    <button id="abrir">Abrir detalle</button>
    <button id="cerrar">Cerrar detalle</button>
    <div id="host"></div>
    <p id="estado">Paneles abiertos en total: 0</p>`;

  const host = document.getElementById('host');
  const estado = document.getElementById('estado');
  let abiertos = 0;

  // ARREGLO 1: el historial guarda referencias debiles y se poda solo.
  // Si el historial solo existe para depurar, lo correcto es no tenerlo.
  const historialDePaneles = [];
  const podarHistorial = () => {
    for (let i = historialDePaneles.length - 1; i >= 0; i--) {
      if (!historialDePaneles[i].deref()) historialDePaneles.splice(i, 1);
    }
  };

  class PanelDeDetalle {
    constructor(id) {
      this.id = id;
      this.control = new AbortController();          // punto unico de limpieza
      const { signal } = this.control;

      this.datos = Array.from({ length: 5000 }, (_, i) => ({
        fila: i, texto: 'valor de la fila ' + i + ' del panel ' + id
      }));

      this.raiz = document.createElement('div');
      this.raiz.className = 'panel';
      this.raiz.innerHTML =
        '<h2>Detalle ' + id + '</h2><ul>' +
        this.datos.slice(0, 50).map(d => '<li>' + d.texto + '</li>').join('') +
        '</ul>';
      host.append(this.raiz);

      // ARREGLO 2: el escucha se da de baja con la señal
      document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') this.cerrar();
      }, { signal });

      // ARREGLO 3: el intervalo se cancela con la misma señal
      const intervalo = setInterval(() => {
        this.raiz.dataset.tick = String(Date.now());
      }, 500);
      signal.addEventListener('abort', () => clearInterval(intervalo), { once: true });

      historialDePaneles.push(new WeakRef(this));
      podarHistorial();
      abiertos++;
      estado.textContent = 'Paneles abiertos en total: ' + abiertos;
    }

    cerrar() {
      this.control.abort();      // corta escucha e intervalo de una vez
      this.raiz.remove();
      this.raiz = null;          // suelta la referencia al arbol
      this.datos = null;         // suelta los datos grandes
    }
  }

  let siguienteId = 1;
  let actual = null;
  document.getElementById('abrir').onclick = () => { actual = new PanelDeDetalle(siguienteId++); };
  document.getElementById('cerrar').onclick = () => { actual?.cerrar(); actual = null; };

  window.ciclo = async (vueltas = 10) => {
    for (let i = 0; i < vueltas; i++) {
      document.getElementById('abrir').click();
      await new Promise(r => setTimeout(r, 60));
      document.getElementById('cerrar').click();
      await new Promise(r => setTimeout(r, 60));
    }
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    podarHistorial();
    console.log(vueltas, 'ciclos completados. Historial vivo:', historialDePaneles.length);
  };

  window.__app = { historialDePaneles, PanelDeDetalle, podarHistorial };
  console.log('Version corregida lista. Ejecuta: await ciclo(10)');
})();

La verificación es la misma comprobación del paso dos, y su resultado tiene que ser binario:

// Verificacion: el criterio es cero, no "menos que antes"
await ciclo(20);
// En la consola de DevTools:
//   queryObjects(__app.PanelDeDetalle)  -> 0 o 1, no 20
//   getEventListeners(document).keydown  -> undefined o 1
console.log('Entradas vivas en el historial:', __app.historialDePaneles.filter(r => r.deref()).length);

Un matiz sobre la referencia débil del arreglo uno: el momento en que deja de resolver no es determinista, así que el recuento puede tardar en llegar a cero. Fuerza la recolección desde el panel de memoria antes de contar. Y la verificación definitiva sigue siendo la de las tres instantáneas: repetir el procedimiento entero y comprobar que el filtro no devuelve ninguna instancia.

Fíjate además en las dos últimas líneas de cerrar. Poner a nulo la raíz y los datos no es imprescindible si el objeto entero se libera, pero es una defensa barata: si más adelante alguien introduce una cuarta retención sobre la instancia, al menos los megabytes de datos y el árbol se habrán soltado. Es la diferencia entre una fuga de un objeto pequeño y una de varios megabytes por ciclo.

El valor de este ejercicio no son las tres fugas: es que ahora tienes un caso de referencia con el que comparar

La razón de dedicar una lección entera a un caso artificial es que la mayor dificultad de cazar una fuga en un proyecto real no es técnica sino de calibración: no sabes qué aspecto tiene una sesión de memoria que va bien, así que no puedes juzgar si lo que estás viendo es normal. Un montón con cuatrocientos mil objetos, ¿es mucho? Doce nodos separados, ¿son un problema? Una comparación entre instantáneas con dos mil líneas de saldo positivo, ¿es una fuga o es una aplicación funcionando? Sin una referencia, esas preguntas no tienen respuesta y la sesión se convierte en una exploración sin criterio de parada, que es la forma más común de abandonar esta herramienta. Haber recorrido este caso entero —con su versión rota y su versión corregida, viendo la diferencia exacta entre las dos en las mismas vistas— te deja una referencia calibrada: sabes qué aspecto tiene una fuga clara en la comparación de instantáneas, sabes que el número redondo de instancias es la señal más fiable, sabes que tres caminos de retención se ven como tres ramas en el panel de retenedores, y sabes que la versión corregida deja el filtro vacío. Por eso merece la pena hacerlo de verdad en lugar de leerlo, y merece la pena repetirlo una vez sobre el proyecto propio en un momento en que no haya ninguna sospecha de fuga: una sesión de tres instantáneas sobre una aplicación sana produce un resultado de referencia que vale mucho más que cualquier tabla de umbrales, porque está medido en tu código, con tu framework y con tus datos. La primera vez que uses esta técnica bajo presión, con un incidente abierto y alguien esperando, esa referencia es lo que va a decidir si el diagnóstico tarda veinte minutos o toda la noche. Y hay un beneficio adicional que aparece a los pocos meses: una vez has visto las tres firmas de este caso, empiezas a reconocerlas leyendo código. Un constructor que registra un escucha en el documento sin señal, un intervalo sin su cancelación, un array de nivel de módulo que acumula instancias: los tres saltan a la vista en una revisión de código, y la fuga que no llega a existir es infinitamente más barata que la que se caza bien.