wandres.dev
MEMORY I · El heap snapshot

Encontrar qué mantiene vivo un objeto concreto

El procedimiento completo desde la sospecha hasta la línea responsable, con las tres técnicas de marcado que hacen localizable un objeto en un montón de un millón de nodos.

⏱ 19 min

Sospechas que una instancia concreta no se está liberando. En un montón con un millón de objetos, encontrarla es el primer problema, y no es trivial: no puedes buscar por identidad, no hay un campo de búsqueda que acepte una referencia, y su constructor puede tener cientos de instancias legítimas. Hay tres técnicas de marcado que convierten esa búsqueda imposible en una operación de diez segundos, y con ellas el procedimiento completo cabe en una página.

🎯 Al terminar esta lección sabrás
  • Marcar un objeto para poder localizarlo en un snapshot posterior.
  • Aplicar el procedimiento completo desde la sospecha hasta la línea responsable.
  • Usar la utilidad de consulta de instancias vivas por constructor.
  • Verificar que un arreglo funciona antes de darlo por bueno.

Las tres técnicas de marcado

Marcado por clase con nombre único. La más simple. Si el objeto sospechoso es un literal anónimo, envuélvelo temporalmente en una clase con un nombre que no exista en ninguna otra parte del código.

// Marcado por clase: el nombre aparece literalmente en la vista de resumen
class SOSPECHOSO_ComponenteTabla {
  constructor(datos) { Object.assign(this, datos); }
}
// En el sitio donde se crea el objeto que investigas:
// const instancia = new SOSPECHOSO_ComponenteTabla({ ... });

Después basta con escribir ese nombre en el filtro de la vista de resumen. Cero ambigüedad.

Marcado por carga identificable. Cuando no puedes cambiar la construcción del objeto, adjúntale una propiedad con un valor grande y único. Se localiza buscando el tamaño.

// Marcado por carga: hace que el objeto destaque por tamano y sea unico
function marcar(obj, etiqueta) {
  obj.__marcaDeDepuracion = etiqueta + '-' + 'M'.repeat(1_000_000);
  return obj;
}
// marcar(instancia, 'tabla-de-pedidos');

Un objeto con un megabyte de relleno aparece inmediatamente al ordenar por tamaño retenido. La contrapartida es que modificas el objeto, así que hay que quitarlo después.

Marcado por consulta de instancias. La utilidad de consola que devuelve todas las instancias vivas de un constructor es la más rápida cuando la clase ya existe, y se trató en las utilidades de la consola. Su ventaja aquí es que se puede usar antes de tomar un snapshot, para saber si merece la pena tomarlo.

// Comprobacion previa: cuantas instancias vivas hay de una clase
// (queryObjects solo existe en la consola, no en codigo de la pagina)
// queryObjects(MiComponente);

// Alternativa programatica: un registro debil con contador, para desarrollo
const registroDeInstancias = (() => {
  const porClase = new Map();
  return {
    registrar(instancia) {
      const nombre = instancia.constructor.name;
      const entrada = porClase.get(nombre) || { creadas: 0, refs: [] };
      entrada.creadas++;
      entrada.refs.push(new WeakRef(instancia));
      porClase.set(nombre, entrada);
    },
    informe() {
      const filas = [];
      for (const [nombre, e] of porClase) {
        const vivas = e.refs.filter(r => r.deref()).length;
        filas.push({ clase: nombre, creadas: e.creadas, vivas, liberadas: e.creadas - vivas });
      }
      console.table(filas.sort((a, b) => b.vivas - a.vivas));
      return filas;
    }
  };
})();

// Uso: llama a registroDeInstancias.registrar(this) en el constructor
// de las clases que quieras vigilar, solo en desarrollo.
window.registroDeInstancias = registroDeInstancias;

El informe de ese registro da la información clave sin abrir ningún panel: cuántas se crearon y cuántas siguen vivas. Si una aplicación que muestra una tabla ha creado cuarenta instancias y las cuarenta siguen vivas, el diagnóstico está hecho. Y como usa referencias débiles, el propio registro no impide la liberación.

⚠️
Cuidado

Cuidado con un efecto que arruina mediciones: la consola retiene los objetos que muestra. Si has hecho console.log de una instancia, esa instancia está viva porque la consola guarda una referencia para poder expandirla. Antes de tomar un snapshot de diagnóstico, limpia la consola. Es una fuente de falsos positivos sorprendentemente frecuente y muy difícil de sospechar.

El procedimiento completo

Uno: formula la expectativa. “Después de cerrar el panel de detalle, no debería quedar ninguna instancia del componente de detalle.” Sin esta frase escrita, no hay diagnóstico posible, solo exploración.

Dos: marca. Con la técnica que corresponda al caso.

Tres: lleva la aplicación al estado inicial y toma un snapshot.

Cuatro: ejecuta el ciclo y vuelve al estado inicial. Abre el panel, ciérralo. Una vez basta para el diagnóstico; varias veces hacen la señal más clara.

Cinco: fuerza la recolección y toma el segundo snapshot.

Seis: filtra por tu marca. Si hay instancias, tienes la fuga confirmada.

Siete: selecciona una y lee su cadena de retención hasta encontrar el primer eslabón que reconozcas.

Ocho: identifica la línea. Si la cadena pasa por un cierre, el panel indica la función; si pasa por un escucha, el tipo de evento y el elemento. Con eso, buscar en el código es directo.

Nueve: arregla y repite el procedimiento entero. Este paso no es opcional y es el que más gente se salta.

Verificar el arreglo

La verificación tiene una trampa que hace que muchos arreglos falsos pasen por buenos: mirar la memoria total no sirve. Puede bajar por motivos ajenos, puede no bajar porque haya otro camino de retención, y varía entre ejecuciones.

La verificación correcta es la misma que el diagnóstico: repetir el ciclo y comprobar que el recuento de instancias marcadas es cero. Es una afirmación binaria, no depende de ruido, y no admite interpretación.

// Verificacion automatizable de que un ciclo no deja instancias vivas
async function verificarCiclo(nombreClase, ciclo, vueltas = 5) {
  const vivas = [];

  for (let i = 0; i < vueltas; i++) {
    await ciclo();
    // Ceder para que se completen desmontajes asincronos
    await new Promise(r => setTimeout(r, 100));
    vivas.push(registroDeInstancias.informe()
      .find(f => f.clase === nombreClase)?.vivas ?? 0);
  }

  console.table(vivas.map((v, i) => ({ vuelta: i + 1, instanciasVivas: v })));
  const crece = vivas.at(-1) > vivas[0];
  console.log(crece
    ? 'FUGA: el numero de instancias vivas crece con cada ciclo.'
    : 'Estable: el ciclo no acumula instancias.');
  return !crece;
}

// Ejemplo de ciclo: sustituye por el flujo real de tu aplicacion
// await verificarCiclo('ComponenteDetalle', async () => {
//   abrirDetalle(42);
//   await new Promise(r => setTimeout(r, 200));
//   cerrarDetalle();
// });

La pausa entre el ciclo y la medición es necesaria porque muchos desmontajes son asíncronos: el framework programa la limpieza para después del fotograma actual, y medir inmediatamente produce falsos positivos.

Cuando el objeto no aparece pero la memoria crece

Un caso que confunde: el recuento de instancias es correcto y la memoria sigue subiendo. Hay tres explicaciones habituales.

La fuga está en otra cosa. Los objetos que se acumulan no son los que sospechabas. Vuelve a la comparación entre snapshots sin filtrar y mira el saldo por constructor.

La memoria no está en el montón de JavaScript. Nodos del documento retenidos, texturas de composición, búferes de audio o de vídeo, datos de un lienzo. El montón puede estar estable mientras el proceso crece.

Es crecimiento legítimo. Una caché que se llena, un histórico que se acumula intencionadamente, un almacén que crece con el uso. Puede seguir siendo un problema —una caché sin límite es una fuga con buenas intenciones— pero el diagnóstico es distinto: no falta una limpieza, falta una política de expulsión.

Marcar antes de medir convierte un problema de búsqueda en un problema de lectura, y esa es la técnica que hace viable todo el panel

El motivo por el que tanta gente considera el panel de memoria inutilizable no tiene que ver con su interfaz sino con que se usa sin preparación, y hay un paralelismo exacto con el panel de rendimiento que merece la pena señalar porque es el mismo principio aplicado dos veces. Un perfil de rendimiento sin marcas propias es un mar de nombres de función del framework en el que hay que reconstruir por deducción qué estaba pasando; con cuatro medidas de aplicación colocadas en los sitios correctos, el mismo perfil se lee como una narración. Un snapshot de memoria sin marcado es un millón de objetos con constructores genéricos entre los que hay que adivinar cuál sobra; con una clase con nombre único o un registro de instancias, el mismo snapshot responde la pregunta en un filtro. En los dos casos, la preparación cuesta minutos y ahorra horas, y en los dos casos se salta sistemáticamente porque parece un rodeo cuando ya estás con el problema delante. La generalización que conviene extraer es una regla de método aplicable mucho más allá de estas dos herramientas: cuando una herramienta te da demasiada información, el trabajo no es aprender a leer mejor sino hacer que tu dominio sea visible dentro de ella. Nombres de clase en lugar de literales anónimos, funciones con nombre en lugar de flechas anónimas en los sitios que aparecen en pilas, medidas de rendimiento con vocabulario de producto, marcas en el snapshot. Todo eso es instrumentación, cuesta muy poco, y cambia la naturaleza del problema: dejas de buscar una aguja y pasas a leer una etiqueta. Y hay un beneficio secundario que se nota con el tiempo: el código instrumentado de esta forma es también el que mejor se depura sin herramientas, porque una pila de llamadas con nombres significativos, unas clases con nombres de dominio y un registro de instancias son exactamente lo que hace legible un informe de error de producción donde no hay ningún panel que abrir.