Nodos desprendidos: quitar del DOM no es liberar
Por qué una referencia a un solo nodo retiene el árbol entero, el catálogo de sitios de donde salen los detached nodes, y cuándo una referencia débil es la respuesta correcta.
elemento.remove() desconecta un nodo del documento. No libera nada. Si algún objeto de JavaScript sigue apuntando a ese nodo —o a cualquiera de sus descendientes— el nodo entero y todo lo que cuelga de él permanece en memoria indefinidamente, con su estilo calculado, sus listeners y sus imágenes decodificadas. Son los nodos desprendidos, y son la fuga más común y más cara de las aplicaciones web, porque una sola referencia olvidada retiene mucho más de lo que aparenta.
- Explicar por qué retener un nodo hoja mantiene vivo todo el árbol desprendido.
- Reconocer los ocho sitios de donde salen los nodos desprendidos en código real.
- Localizarlos con el panel de memoria y con la herramienta específica del navegador.
- Decidir cuándo una referencia débil es la solución correcta y cuándo es un parche.
Una referencia, un árbol entero
Un nodo del DOM tiene referencias a su padre, a sus hijos y a sus hermanos. El grafo es bidireccional. Y eso tiene una consecuencia que sorprende la primera vez y que explica por qué estas fugas son tan desproporcionadas:
Si retienes cualquier nodo de un árbol desprendido, retienes el árbol completo. No solo sus descendientes: también sus ancestros, porque desde el nodo se llega al padre, y desde el padre a todos los hermanos y sus subárboles.
Guardar una referencia al span del título de un panel que tenía trescientos elementos retiene los trescientos. Y con ellos su estilo calculado, sus listeners, los datos que esos listeners capturaron en sus cierres, y las imágenes que hubiera dentro con su versión decodificada, que es la parte pesada de una imagen: un JPEG de 200 KB descomprimido a 1200 por 800 píxeles ocupa casi cuatro megabytes en memoria.
Es fácil de comprobar:
// Repro minima. Ejecutar en cualquier pagina.
let referencia = null;
function crearYDestruir() {
const panel = document.createElement('div');
for (let i = 0; i < 500; i++) {
const fila = document.createElement('div');
fila.textContent = 'fila ' + i;
panel.appendChild(fila);
}
document.body.appendChild(panel);
// Guardamos una hoja cualquiera del arbol.
referencia = panel.lastElementChild;
panel.remove(); // fuera del documento... pero no de la memoria
}
crearYDestruir();
// En el panel de memoria: filtra por "Detached". Veras 501 nodos,
// no uno. El camino de retenedores sale de `referencia`.
El caso extremo de esto es un iframe. Si retienes cualquier cosa de un iframe desprendido —su contentWindow, su contentDocument, o un elemento de dentro— mantienes vivo un documento entero con su propio heap, sus propios estilos y su propio árbol. Es la fuga individual más cara que existe en la plataforma, y aparece con una frecuencia deprimente en integraciones de terceros que guardan el window del iframe para hablar con él y nunca lo sueltan.
De dónde salen: el catálogo
Ocho fuentes, ordenadas por frecuencia en código real.
Cachés de elementos. Un Map o un objeto que guarda nodos por identificador para no volver a buscarlos. Perfectamente razonable, salvo que nadie borra la entrada cuando el nodo se destruye.
Arrays de nodos guardados. El resultado de un querySelectorAll almacenado en una propiedad de instancia. La lista es estática, pero mantiene referencias fuertes a cada nodo.
Cierres en manejadores. Un manejador registrado en document o en window que menciona un nodo local. Mientras el listener exista, el ámbito existe, y el nodo con él.
Referencias de framework. Los ref de React, las plantillas de Vue, las variables bind:this de Svelte. Los frameworks las limpian al desmontar, pero si tú copias el valor a otro sitio —a un módulo, a un store global, a un array— la copia sobrevive al desmontaje.
Estructuras auxiliares indexadas por elemento. El patrón de “guardo datos asociados a este nodo” implementado con un Map normal. Todas las claves son referencias fuertes.
Observadores no desconectados. Un MutationObserver, un ResizeObserver o un IntersectionObserver mantiene referencias a sus objetivos hasta que llamas a disconnect().
Temporizadores. Un setInterval cuyo callback menciona un nodo lo retiene para siempre, porque el temporizador es una raíz.
Colecciones vivas. getElementsByClassName y getElementsByTagName devuelven colecciones vivas ligadas al documento. Guardarlas retiene el documento y además tiene el efecto secundario de que reconsultan en cada acceso.
Las herramientas
El filtro del snapshot. Toma un snapshot y escribe Detached en el campo de filtro del listado. Aparecen agrupados por tipo (Detached HTMLDivElement, Detached HTMLIFrameElement) con su cuenta y su tamaño retenido. Selecciona el de mayor tamaño retenido y sigue el camino de retenedores, exactamente igual que en la técnica de los tres snapshots.
La herramienta de elementos desprendidos. El navegador tiene una vista específica en el panel de memoria que lista los nodos desprendidos con su árbol y su retenedor, sin necesidad de tomar snapshots ni de saber leerlos. Es lo primero que hay que probar: si tu fuga es de nodos, la resuelve en dos minutos.
La pista de contadores en el perfil. Al grabar un perfil de rendimiento con la casilla de memoria activada, aparece una serie temporal con el número de nodos del DOM y el número de listeners. Es la mejor señal de alerta temprana que existe: si haces un ciclo de uso diez veces y la línea de nodos sube en escalones que no bajan, tienes nodos desprendidos aunque el heap de JavaScript parezca estable.
Esa última observación merece énfasis porque es contraintuitiva: los nodos del DOM no viven en el heap de JavaScript. Viven en la memoria del motor de renderizado, y el envoltorio de JavaScript que ves desde tu código es pequeño. Una fuga de cincuenta mil nodos puede añadir doscientos megabytes al proceso y apenas mover usedJSHeapSize. Si solo vigilas el heap de JavaScript, no la vas a ver.
Referencias débiles y cuándo son la respuesta
Hay tres herramientas y se confunden constantemente.
WeakMap. Sus claves son referencias débiles: si la clave deja de estar referenciada desde otro sitio, la entrada desaparece sola. Es la estructura correcta para “datos asociados a un elemento”.
El error mortal, y es muy frecuente, es invertir los papeles:
// CORRECTO: el elemento es la CLAVE. Si el elemento muere,
// la entrada desaparece y los datos con ella.
const metadatos = new WeakMap();
metadatos.set(elemento, { seleccionado: true, orden: 3 });
// FUGA: el elemento es el VALOR. Una WeakMap no debilita los
// valores, asi que esto retiene el elemento igual que un Map.
const porId = new WeakMap();
porId.set(objetoClave, elemento); // <- retiene el elemento
Los valores de un WeakMap son referencias fuertes. Siempre. Si necesitas mapear un identificador a un elemento, WeakMap no te sirve, porque la clave sería una cadena y las claves de WeakMap tienen que ser objetos.
WeakRef. Una referencia individual que no impide la recolección. Se lee con deref(), que devuelve el objeto o undefined si ya se recolectó. Es la herramienta para “quiero guardar esto si sigue vivo, pero no quiero mantenerlo vivo”.
class RegistroDePaneles {
#paneles = new Map(); // id -> WeakRef
registrar(id, elemento) {
this.#paneles.set(id, new WeakRef(elemento));
}
obtener(id) {
const ref = this.#paneles.get(id);
const el = ref?.deref();
if (!el) {
// El elemento se recolecto: limpiamos la entrada muerta.
this.#paneles.delete(id);
return null;
}
return el;
}
}
Fíjate en que el Map de WeakRef sigue creciendo con entradas muertas: el WeakRef no retiene el elemento, pero el Map retiene el WeakRef. Por eso hay que limpiar al consultar, o combinarlo con un registro de finalización.
FinalizationRegistry. Ejecuta un callback cuando un objeto se recolecta. La especificación no garantiza ni que se llame ni cuándo, así que no puede usarse para corrección: no cierres conexiones ni liberes recursos ahí. Su uso legítimo es el diagnóstico, y ahí es excelente:
// Solo para tests y depuracion.
const vigilancia = new FinalizationRegistry((etiqueta) => {
console.log('recolectado:', etiqueta);
});
vigilancia.register(panel, 'panel de detalle #' + id);
// Si tras cerrar el panel y forzar recoleccion no aparece el mensaje,
// hay algo reteniendolo.
Y la advertencia que importa más que las tres herramientas juntas: una referencia débil casi nunca es la solución correcta a una fuga. Si tienes una fuga es porque alguien guardó algo y no lo soltó; la reparación correcta es soltarlo en el sitio donde corresponde, no debilitar la referencia para que el recolector arregle tu desorden. Debilitar convierte un error determinista en uno no determinista: el objeto ya no se libera cuando tú decides, sino cuando al motor le apetece, y el bug que aparezca dependerá del momento de la recolección. Usa WeakMap cuando el diseño es genuinamente “datos accesorios de un objeto cuya vida no controlo”. Usa remove y delete cuando sí la controlas, que es casi siempre.
Esta es la propiedad que hace que las fugas de nodos sean cualitativamente distintas de las de objetos, y la que explica por qué una aplicación puede pasar de ir bien a ser inusable en veinte minutos sin que nada del código haya cambiado. Cuando olvidas soltar la referencia a un objeto normal, pierdes ese objeto y lo que él referencie hacia abajo: el daño es proporcional a lo que descuidaste. Cuando olvidas soltar la referencia a un nodo del DOM, pierdes todo el árbol al que pertenecía, hacia arriba y hacia abajo, más los estilos calculados de cada nodo, más los mapas de listeners, más los mapas de datos asociados, más los bitmaps decodificados de cada imagen que hubiera dentro. El daño es proporcional a la vista completa que ese nodo habitaba. Y como las vistas modernas se construyen y se destruyen constantemente —cada navegación, cada modal, cada panel, cada fila que se expande—, el multiplicador se aplica una y otra vez. Un solo Map sin limpiar en un componente de detalle que el usuario abre cuarenta veces por sesión son cuarenta árboles completos en memoria, cada uno con sus imágenes descomprimidas. Esa es la aritmética que convierte “una referencia olvidada” en “la pestaña se recarga sola en el móvil”. Hay dos consecuencias prácticas que conviene sacar de aquí. La primera es de diagnóstico: cuando veas nodos desprendidos, ordena por tamaño retenido y ataca el mayor, porque casi con seguridad hay una sola causa y el resto son sus descendientes; la lista de doscientas entradas no son doscientos bugs. La segunda es de diseño y es más importante: la unidad natural de limpieza en una interfaz no es la referencia, es la vista. Si cada componente tiene una función de destrucción que desregistra sus listeners, desconecta sus observadores, cancela sus temporizadores y vacía sus mapas, las fugas de nodos desaparecen como categoría. Si la limpieza está repartida por sitios distintos según quién se acordó de hacerla, volverán todas las semanas.
- Ejecuta la reproducción mínima y confirma en el snapshot que se retienen 501 nodos y no uno.
- Abre la herramienta de elementos desprendidos en tu aplicación después de diez ciclos de uso. Ordena por tamaño retenido.
- Graba un perfil con la casilla de memoria activada y observa la línea de nodos del DOM durante diez ciclos. Anota si sube en escalones.
- Busca en tu código todos los
new Map()cuyas claves sean elementos y decide, uno a uno, si deben serWeakMap. - Instrumenta un componente con
FinalizationRegistryy verifica que se recolecta al desmontarlo. Si no aparece el mensaje, sigue el camino de retenedores.