wandres.dev
MEMORY I · El heap snapshot

Leer un snapshot: las vistas, la distancia y los nodos sintéticos

Las formas de recorrer el grafo, qué agrupa cada una, qué significa la columna de distancia, y cómo interpretar los nombres entre paréntesis que no corresponden a ningún objeto tuyo.

⏱ 18 min

Un snapshot recién tomado presenta una tabla con cientos de filas cuyos nombres son en buena parte incomprensibles: constructores que no reconoces, entradas entre paréntesis que no corresponden a nada que hayas escrito, y columnas cuyo significado no es evidente. Nada de eso es opcional para trabajar con memoria, y afortunadamente el vocabulario es pequeño: cuatro vistas, cinco columnas y media docena de nombres sintéticos.

🎯 Al terminar esta lección sabrás
  • Elegir la vista adecuada según lo que se esté buscando.
  • Interpretar la agrupación por constructor y las columnas de la tabla.
  • Explicar qué mide la columna de distancia y para qué sirve.
  • Reconocer los nodos sintéticos del motor y qué representa cada uno.

Las vistas

Resumen. Agrupa los objetos por su constructor y muestra, para cada grupo, cuántas instancias hay y cuánta memoria ocupan. Es la vista por defecto y la que se usa el noventa por ciento del tiempo. Su valor está en el recuento: si esperabas tener tres instancias de una clase y hay cuatrocientas, ya tienes el diagnóstico sin mirar nada más.

Comparación. Muestra la diferencia entre dos snapshots: qué objetos se han creado, cuáles se han liberado, y el saldo neto por constructor. Es la vista donde vive el diagnóstico de fugas, y sus columnas de “nuevos” y “eliminados” son la información central.

Contención. Presenta el grafo como un árbol navegable desde las raíces, en lugar de agrupar por tipo. Sirve para explorar la estructura: qué cuelga del objeto global, qué contiene un objeto concreto, cómo está organizado el árbol de dominación. Es la vista menos usada y la más útil para entender una aplicación ajena.

Estadísticas. Un reparto del montón por categorías generales: código, cadenas, arrays JavaScript, arrays tipados, sistema. Da la orientación general en un vistazo: un montón dominado por cadenas y otro dominado por código son dos situaciones muy distintas.

💡
Tip

El campo de filtro de la vista de resumen acepta texto libre y filtra por nombre de constructor. Escribir el nombre de una clase tuya salta directamente a sus instancias, y escribir un fragmento como el prefijo de tus componentes agrupa todo tu código de golpe. Es la forma de convertir una lista de mil filas en las diez que te importan.

Las columnas

Constructor. El nombre bajo el que se agrupa. Para objetos creados con class o con función constructora, ese nombre. Para objetos literales, un nombre genérico de objeto. Para elementos del documento, su interfaz.

Distancia. El número de saltos desde la raíz más cercana hasta ese objeto por el camino más corto. Es una columna que casi nadie mira y que es extraordinariamente útil, por dos motivos. El primero: los objetos con distancia muy pequeña están colgando prácticamente del objeto global, y si algo que debería ser temporal tiene distancia dos, es que alguien lo guardó en una variable global. El segundo: una distancia inesperadamente grande delata cadenas de retención largas, que suelen ser accidentales.

Tamaño superficial. La memoria del objeto en sí. Tiene lección propia.

Tamaño retenido. La memoria que se liberaría si ese objeto desapareciera. Tiene la misma lección propia, y es la columna por la que hay que ordenar casi siempre.

Número de objetos. En la vista de resumen, cuántas instancias hay de ese constructor, con el porcentaje sobre el total.

La ordenación por defecto no suele ser la útil. La secuencia de trabajo habitual es ordenar por tamaño retenido en la vista de resumen para ver qué grupos dominan la memoria, y ordenar por número de objetos cuando se busca una fuga por acumulación, que suele manifestarse como muchas instancias pequeñas más que como una grande.

Los nodos sintéticos

Los nombres entre paréntesis no son objetos que hayas escrito: son estructuras internas del motor que el snapshot expone porque forman parte del grafo de retención. Reconocerlos evita mucha confusión.

(closure). El contexto capturado por una función. Contiene las variables del ámbito exterior a las que la función accede. Es el nodo más importante de todos para diagnosticar fugas, porque es el mecanismo por el que una función mantiene vivos objetos que aparentemente ya nadie usa.

(array) y (object shape). Estructuras internas: el almacenamiento de los elementos de un array, y los descriptores de forma que el motor usa para optimizar el acceso a propiedades.

(string) y (concatenated string) y (sliced string). Cadenas, incluidas las representaciones perezosas: una concatenación puede guardarse como un nodo que apunta a sus dos mitades sin materializar el resultado, y una subcadena puede ser una vista sobre la original. Esto tiene una consecuencia práctica sorprendente: una subcadena pequeña puede mantener viva una cadena enorme, porque solo guarda un rango sobre ella.

(system) y (compiled code). Estructuras del motor y código compilado.

Detached HTMLDivElement y familia. Elementos del documento que ya no están en el árbol pero siguen en memoria. Son el objetivo principal del diagnóstico de fugas de interfaz y tienen lección propia.

(GC roots) y sus subgrupos. Los puntos de partida de la alcanzabilidad: la pila de ejecución, el objeto global, los manejadores del navegador.

Un detalle de la vista de resumen que ayuda a leer rápido: los objetos que se pueden alcanzar directamente desde JavaScript se distinguen visualmente de los que solo son alcanzables a través de estructuras internas. Esa distinción es la que hace evidentes los nodos separados retenidos únicamente por su árbol.

Un experimento para practicar

La forma de aprender a leer estas vistas es producir a propósito objetos con formas conocidas y buscarlos. Este fragmento crea tres situaciones distinguibles.

// Crea tres patrones de retencion identificables en un snapshot
(() => {
  // 1. Una clase con nombre propio y muchas instancias: facil de filtrar
  class MarcadorDeMemoria {
    constructor(i) {
      this.indice = i;
      this.relleno = new Array(1000).fill('x' + i);
    }
  }
  window.__instancias = Array.from({ length: 500 }, (_, i) => new MarcadorDeMemoria(i));

  // 2. Un cierre que captura algo grande sin usarlo aparentemente
  function crearCierrePesado() {
    const enorme = new Array(200000).fill('dato');
    const pequeno = { id: Math.random() };
    return function usaSoloElPequeno() { return pequeno.id; };
  }
  window.__cierre = crearCierrePesado();

  // 3. Una subcadena que retiene la cadena original entera
  const textoEnorme = 'a'.repeat(2_000_000);
  window.__fragmento = textoEnorme.slice(0, 10);

  console.log('Listo. Toma un snapshot y busca:');
  console.log('  - "MarcadorDeMemoria" en el filtro de la vista de resumen');
  console.log('  - el nodo (closure) de usaSoloElPequeno y su ambito capturado');
  console.log('  - el tamano retenido de __fragmento, que deberia sorprenderte');

  window.__limpiar = () => {
    delete window.__instancias; delete window.__cierre; delete window.__fragmento;
    console.log('Referencias eliminadas. Toma otro snapshot y compara.');
  };
})();

Los tres casos enseñan cosas distintas. El primero, a filtrar por constructor y a contar. El segundo, que un cierre captura todo el ámbito, no solo lo que usa: la variable grande sigue viva aunque la función devuelta no la mencione. El tercero, que una cadena de diez caracteres puede retener dos millones, porque internamente es una vista.

El último de los tres es el que más sorprende y el que explica fugas reales: guardar un fragmento de una respuesta grande puede mantener viva la respuesta entera. La forma de romper la vista es forzar una copia real de la cadena.

La columna de distancia es el detector de globales accidentales más rápido que existe

De todas las columnas, la de distancia es la que más información da por unidad de esfuerzo y la que casi nadie usa, así que merece una recomendación concreta. La distancia es la longitud del camino más corto desde una raíz, y las raíces son esencialmente el objeto global y las pilas activas. Eso significa que la distancia mide cuán directamente cuelga algo del estado global de la aplicación, y ahí está su valor diagnóstico. En una aplicación sana, los objetos de trabajo tienen distancias medias: cuelgan de un componente, que cuelga de un árbol, que cuelga de una raíz de aplicación, que cuelga del global. Cinco, ocho, quince saltos. Cuando ordenas por distancia ascendente y encuentras objetos de tu dominio —una respuesta de API, una instancia de un componente, un manejador— con distancia dos o tres, has encontrado algo que está pegado directamente al objeto global, y eso casi nunca es intencionado: es una variable declarada sin const en un ámbito no estricto, una propiedad asignada a window para depurar y olvidada, un registro global de instancias, o un singleton que guarda referencias a todo lo que pasa por él. Cualquiera de esos cuatro es una fuga garantizada, porque el objeto global no se recolecta nunca mientras la página viva. La comprobación cuesta quince segundos y es la primera que conviene hacer al abrir un snapshot de una aplicación que sospechas que fuga. Y hay una segunda lectura de la misma columna, en el extremo contrario: una distancia anormalmente grande, de decenas o cientos de saltos, indica una cadena de retención larga, y las cadenas largas suelen ser accidentales. Un caso típico es una lista enlazada de estados donde cada uno referencia al anterior: en lugar de liberarse el histórico, cada entrada mantiene viva a toda su ascendencia, y basta con que la última esté referenciada para que ninguna se libere. Ese patrón es invisible en la vista de resumen, porque cada objeto individual es pequeño, y salta a la vista ordenando por distancia.