wandres.dev
MEMORY I · El heap snapshot

El camino de retención: leer por qué algo sigue vivo

El panel que muestra la cadena de referencias desde una raíz hasta un objeto, cómo se leen los nombres de las aristas, y el procedimiento para elegir dónde cortar.

⏱ 18 min

Encontrar el objeto que no debería estar vivo es la mitad fácil. La mitad que decide si la sesión sirve de algo es responder por qué sigue vivo, y esa respuesta la da un panel concreto: la lista de retenedores, que muestra la cadena de referencias que va desde una raíz hasta el objeto seleccionado. Leerla bien es una habilidad muy específica, se aprende en una tarde, y prácticamente nadie la tiene.

🎯 Al terminar esta lección sabrás
  • Localizar el panel de retenedores y navegar la cadena hasta una raíz.
  • Interpretar el nombre de cada arista y deducir el tipo de referencia que representa.
  • Elegir el eslabón correcto donde cortar la cadena.
  • Reconocer los cinco patrones de retención más frecuentes en su forma de cadena.

Cómo se lee

Al seleccionar cualquier objeto en la tabla, el panel inferior muestra sus retenedores: los objetos que lo referencian. Cada uno se puede expandir para ver los suyos, y así sucesivamente hasta llegar a una raíz.

Se lee de abajo arriba en la lógica y de arriba abajo en la pantalla: la primera fila es quien referencia directamente al objeto seleccionado, y cada nivel de anidamiento es un paso más hacia la raíz. La cadena que importa es la que termina en una raíz, y el panel la marca.

Cada fila tiene dos partes, y las dos son información:

El nombre de la arista, que dice cómo se referencia. Un nombre de propiedad significa que hay un objeto con esa propiedad apuntando al tuyo. Un número entre corchetes significa que es un elemento de un array en esa posición. Un nombre precedido de una indicación de contexto significa que es una variable capturada por un cierre. Y hay nombres internos como map, elements o properties que corresponden a estructuras del motor.

El objeto retenedor, con su constructor y sus tamaños.

ℹ️
Nota

Cuando hay varios caminos, el panel los muestra todos, y eso es información valiosa por sí misma: si hay tres caminos de retención, cortar uno no libera nada. Antes de escribir el arreglo hay que saber cuántos hay, porque el arreglo tiene que cortarlos todos o cortar el que es común a los tres.

Los cinco patrones y su forma de cadena

Cada patrón de fuga produce una silueta reconocible en la cadena de retenedores. Reconocerla ahorra el trabajo de reconstruir el razonamiento.

Registro global. La cadena es cortísima: objeto, propiedad de algún registro, objeto global. Distancia dos o tres. Significa que alguien guardó una referencia en un sitio que vive tanto como la página. El arreglo es quitarla, o usar una referencia débil si el registro tiene que existir.

Colección que crece. La cadena pasa por un elemento numerado de un array, o por una entrada de un mapa. Se reconoce porque el retenedor inmediato es una estructura de datos y no un objeto de dominio. El diagnóstico se confirma mirando cuántos elementos tiene esa colección. El arreglo es podarla.

Cierre. La cadena pasa por un nodo de contexto de función. Es el patrón más difícil de leer y el más común. Significa que una función viva captura una variable que apunta al objeto. La pregunta que hay que responder es quién mantiene viva esa función, y la respuesta está un nivel más arriba en la cadena: casi siempre un manejador de eventos, un temporizador, o una devolución de llamada registrada en algún sitio.

Manejador de evento. La cadena termina en una raíz que corresponde al registro de escuchas del navegador. Es la firma de un escucha que nunca se quitó. El objeto está vivo porque la función manejadora lo captura, y la función está viva porque el navegador la tiene registrada.

Temporizador. Similar, pero la raíz corresponde al registro de temporizadores. Un intervalo que nunca se cancela mantiene viva su devolución de llamada y todo lo que capture, indefinidamente.

Elegir dónde cortar

Con la cadena completa a la vista, hay que decidir en qué eslabón intervenir, y no siempre es el más cercano al objeto.

El criterio es cortar en el eslabón cuya vida no deberías controlar y sin embargo estás alargando. Un ejemplo aclara la diferencia: si un componente desmontado sigue vivo porque un escucha del documento captura su instancia, hay dos cortes posibles. Uno es que el componente no se capture a sí mismo en el escucha, lo cual es una acrobacia frágil. El otro es quitar el escucha al desmontar, que es lo correcto: el problema no era la captura sino que el escucha sobreviviera al componente.

Tres preguntas ordenan la decisión.

¿Cuál es el eslabón que representa una relación que ya no debería existir? Ese es el candidato natural.

¿Ese corte es una limpieza que ya debería estar en el código? Casi siempre sí: quitar un escucha, cancelar un temporizador, darse de baja de una suscripción, abortar una petición. Si el corte corresponde a una limpieza que falta, ese es el arreglo correcto y además arregla otras cosas.

¿Hay una referencia débil que resuelva el caso? Cuando la relación tiene que existir pero no debería mantener nada vivo —una caché por objeto, un registro de instancias para depuración, metadatos asociados a un nodo— la estructura correcta es una que no cuente para la alcanzabilidad.

// Las tres estructuras que no impiden la recoleccion
(() => {
  // 1. Un mapa cuyas claves no se mantienen vivas por estar en el mapa
  const metadatos = new WeakMap();
  const asociar = (nodo, datos) => metadatos.set(nodo, datos);
  const leer = nodo => metadatos.get(nodo);

  // 2. Un conjunto debil, para marcar objetos sin retenerlos
  const yaProcesados = new WeakSet();
  const marcar = obj => yaProcesados.add(obj);
  const estaMarcado = obj => yaProcesados.has(obj);

  // 3. Una referencia debil explicita, cuando hace falta poder consultar
  function cacheDebil() {
    const almacen = new Map();
    return {
      guardar(clave, valor) { almacen.set(clave, new WeakRef(valor)); },
      obtener(clave) {
        const ref = almacen.get(clave);
        const v = ref && ref.deref();
        if (!v) almacen.delete(clave);   // limpieza perezosa de entradas muertas
        return v;
      },
      get tamano() { return almacen.size; }
    };
  }

  // Demostracion: el objeto se libera aunque este en las tres estructuras
  let temporal = { id: 'se-va-a-liberar', carga: new Array(50000).fill(0) };
  asociar(temporal, { creado: Date.now() });
  marcar(temporal);
  const cache = cacheDebil();
  cache.guardar('clave', temporal);

  console.log('Antes de soltar:', leer(temporal), estaMarcado(temporal), !!cache.obtener('clave'));
  temporal = null;
  console.log('Referencia soltada. Fuerza recoleccion desde el panel de memoria y');
  console.log('comprueba que el objeto no aparece en el siguiente snapshot.');

  window.__cacheDebil = cache;
})();

Un aviso importante sobre la tercera estructura: el momento en que una referencia débil deja de resolver no es determinista. Depende de cuándo decida ejecutarse el recolector, y no se debe construir lógica que dependa de ello. Su uso correcto es como caché oportunista, no como mecanismo de control de vida.

Un consejo de procedimiento que ahorra tiempo: la cadena de retenedores puede ser muy larga y expandirla entera a mano es tedioso. Dos atajos.

Usa la vista de contención en paralelo. Presenta el árbol de dominación, y a veces el camino se ve más claro desde arriba.

Busca el primer nodo con nombre reconocible. No hace falta llegar a la raíz. En cuanto la cadena pasa por un objeto de tu aplicación cuyo ciclo de vida entiendes, ya tienes suficiente: la pregunta pasa a ser por qué ese objeto sigue vivo, que es un problema más pequeño y que muchas veces sabes responder sin más análisis.

Casi todas las fugas de una aplicación web son una suscripción que sobrevivió a su suscriptor

Después de leer un número suficiente de cadenas de retención en aplicaciones reales, aparece un patrón que unifica el ochenta o noventa por ciento de los casos, y conocerlo permite acertar el diagnóstico antes de abrir el panel. La forma canónica de una fuga es: algo de vida larga guarda una referencia a algo de vida corta. El objeto de vida larga puede ser el documento, la ventana, un almacén global, un bus de eventos, un observador, un temporizador o una conexión abierta. El de vida corta es un componente, una vista, un modal, una fila de una tabla, una instancia asociada a un dato concreto. Mientras el de vida corta esté registrado en el de vida larga, no se libera nunca, y como el de vida larga sobrevive a muchos ciclos, se acumulan. Esa formulación explica por qué las fugas en la web tienen un carácter tan distinto de las de otros entornos: aquí casi nunca son punteros perdidos ni memoria sin liberar, sino relaciones que no se deshicieron. Y como consecuencia, la prevención tiene un principio único y muy simple de enunciar: quien crea una relación tiene que deshacerla, y el momento de escribir la limpieza es el mismo en el que se escribe el registro, no después. Cada addEventListener con su removeEventListener, cada setInterval con su clearInterval, cada observe con su disconnect, cada suscripción con su baja, cada petición con su señal de aborto. En la práctica esto se consigue mejor con una disciplina estructural que con buena voluntad: una función de creación que devuelva su propia función de limpieza, un controlador de aborto por ciclo de vida cuya señal se pasa a todos los registros —los escuchas de eventos la aceptan directamente, y una sola llamada de aborto los quita todos de golpe—, o el mecanismo de limpieza que ofrezca tu framework. Cualquiera de los tres convierte el problema de “acordarse de limpiar en veinte sitios” en “acordarse de una vez”, que es una tarea que los humanos sí hacemos bien. Y hay una prueba de fuego para saber si tu código está en el lado bueno de esta línea, que no requiere ningún panel: si no puedes señalar, para cada registro que hace tu componente, la línea exacta que lo deshace, tienes una fuga aunque todavía no la hayas medido.