Nodos separados: elementos que ya no están en la página y siguen en memoria
Qué es un nodo separado, las seis formas de crearlos sin querer, cómo se encuentran en un snapshot, y por qué un solo nodo puede retener un árbol de miles.
Un nodo separado es un elemento que fue quitado del documento y que sigue vivo porque alguien en JavaScript guarda una referencia a él. Es la forma más común de fuga en una aplicación web, la más costosa en memoria por unidad de descuido, y la que tiene la firma más fácil de reconocer una vez sabes buscarla. Y tiene una propiedad que la hace especialmente dañina: retener un solo nodo puede retener el subárbol entero al que pertenecía.
- Definir qué es un nodo separado y por qué un solo nodo retiene su árbol.
- Encontrar los nodos separados en un snapshot y contarlos.
- Enumerar las seis formas habituales de crearlos sin querer.
- Aplicar el patrón de limpieza que los elimina de forma estructural.
Qué son y por qué pesan tanto
Cuando quitas un elemento del documento, deja de estar en el árbol y deja de renderizarse. Lo que no ocurre automáticamente es que se libere: sigue siendo un objeto de JavaScript, y si algo lo referencia, sigue vivo.
Hasta ahí es una fuga como cualquier otra. Lo que la hace especial es la topología del árbol del documento: cada nodo referencia a su padre, a sus hijos y a sus hermanos. Eso significa que retener un único nodo hoja mantiene vivo a su padre, que mantiene vivos a todos sus hermanos, que mantiene vivo a su abuelo, y así hasta la raíz del subárbol separado.
La consecuencia práctica es desproporcionada. Guardar una referencia a un único botón dentro de una tabla desmontada de dos mil filas mantiene viva la tabla entera: los dos mil elementos, sus nodos de texto, sus atributos, y todos los objetos de JavaScript asociados a ellos. Unos megabytes por una variable olvidada.
En el snapshot esto se ve con claridad: un nodo separado con un tamaño superficial de doscientos bytes y un tamaño retenido de varios megabytes es exactamente esta situación.
En la vista de resumen, los objetos alcanzables directamente desde JavaScript se distinguen visualmente de los que solo son alcanzables a través de otros nodos separados. Esa distinción es el atajo del diagnóstico: el que está marcado como directamente alcanzable es el que tu código referencia; todos los demás están vivos por culpa de él. Buscar el que está marcado te lleva directamente a la referencia culpable, sin recorrer miles de nodos.
Encontrarlos
En la vista de resumen de un snapshot, el filtro acepta la palabra Detached. Eso agrupa todos los nodos separados por su interfaz: Detached HTMLDivElement, Detached HTMLTableRowElement, y así.
La lectura tiene tres pasos.
Cuenta. Un puñado puede ser normal, especialmente si el framework mantiene alguna caché de plantillas. Miles no lo es nunca.
Ordena por tamaño retenido. El de arriba es la raíz del subárbol separado más grande, y es donde hay que mirar.
Busca el que está referenciado directamente desde JavaScript y lee su cadena de retención.
Para una comprobación rápida sin abrir el panel, este fragmento estima el problema desde la consola:
// Detector aproximado de nodos separados retenidos por codigo propio
(() => {
// Estrategia: recorrer objetos globales y estructuras conocidas buscando
// nodos que no esten conectados al documento.
const separados = [];
const vistos = new WeakSet();
const esNodo = v => v instanceof Node;
const explorar = (valor, ruta, profundidad = 0) => {
if (profundidad > 6 || valor === null || typeof valor !== 'object') return;
if (vistos.has(valor)) return;
vistos.add(valor);
if (esNodo(valor)) {
if (!valor.isConnected && valor.nodeType === 1) {
separados.push({
ruta,
etiqueta: valor.tagName.toLowerCase(),
descendientes: valor.querySelectorAll('*').length,
texto: (valor.textContent || '').slice(0, 40)
});
}
return;
}
if (Array.isArray(valor)) {
valor.slice(0, 200).forEach((v, i) => explorar(v, ruta + '[' + i + ']', profundidad + 1));
return;
}
if (valor instanceof Map) {
let i = 0;
for (const [k, v] of valor) { if (i++ > 200) break; explorar(v, ruta + '.get(...)', profundidad + 1); }
return;
}
if (valor instanceof Set) {
let i = 0;
for (const v of valor) { if (i++ > 200) break; explorar(v, ruta + '.(set)', profundidad + 1); }
return;
}
for (const clave of Object.keys(valor).slice(0, 60)) {
let v; try { v = valor[clave]; } catch { continue; }
explorar(v, ruta + '.' + clave, profundidad + 1);
}
};
for (const clave of Object.keys(window)) {
let v; try { v = window[clave]; } catch { continue; }
if (v && typeof v === 'object') explorar(v, 'window.' + clave, 0);
}
if (!separados.length) console.log('No se han encontrado nodos separados en el ambito global explorado.');
else {
console.warn('Nodos separados alcanzables desde el ambito global:');
console.table(separados.sort((a, b) => b.descendientes - a.descendientes).slice(0, 25));
}
})();
Este detector solo ve lo que cuelga del ámbito global y por tanto no encuentra las fugas dentro de cierres, que son la mayoría. Su valor es que es instantáneo y encuentra la categoría más tonta y más frecuente: la referencia guardada en una variable global durante una depuración y olvidada.
Las seis formas de crearlos
Una referencia guardada en una variable de larga vida. Un módulo que guarda let elementoActivo y no lo pone a nulo al desmontar.
Un array de nodos que nunca se vacía. Un registro de elementos observados, una lista de destinos de animación, una colección de filas para poder recorrerlas después.
Un cierre que captura un nodo. El caso más frecuente de todos. Una función registrada en cualquier sitio que usa un nodo en su cuerpo lo mantiene vivo mientras la función viva.
Un escucha de eventos en un elemento superviviente. Si el manejador captura el nodo desmontado y el escucha está en el documento o en la ventana, el nodo vive mientras la página.
Una clave o un valor en un mapa fuerte. Asociar metadatos a nodos con un Map normal en lugar de con uno débil retiene el nodo indefinidamente.
Un observador no desconectado. Los observadores de intersección, de redimensionamiento y de mutación mantienen referencias a los nodos que observan. Si no se desconectan al desmontar, retienen todo lo observado.
El patrón que lo elimina estructuralmente
Depender de acordarse de limpiar cada referencia no funciona a escala. El patrón que sí funciona es agrupar todas las limpiezas bajo un único mecanismo de cancelación.
// Un componente con una unica señal de cancelacion para todas sus limpiezas
function crearComponente(contenedor, datos) {
const control = new AbortController();
const { signal } = control;
const raiz = document.createElement('section');
raiz.innerHTML = '<button data-accion="cerrar">Cerrar</button><ul></ul>';
contenedor.append(raiz);
const lista = raiz.querySelector('ul');
for (const d of datos) {
const li = document.createElement('li');
li.textContent = String(d);
lista.append(li);
}
// 1. Escuchas: la señal los quita todos de golpe al abortar
raiz.addEventListener('click', alPulsar, { signal });
document.addEventListener('keydown', alTeclado, { signal });
window.addEventListener('resize', alRedimensionar, { signal });
// 2. Observadores: se desconectan al abortar
const observador = new ResizeObserver(() => { /* ... */ });
observador.observe(raiz);
signal.addEventListener('abort', () => observador.disconnect(), { once: true });
// 3. Temporizadores: se cancelan al abortar
const intervalo = setInterval(() => { /* ... */ }, 1000);
signal.addEventListener('abort', () => clearInterval(intervalo), { once: true });
// 4. Peticiones en vuelo: se abortan con la misma señal
fetch('/api/datos', { signal }).catch(e => {
if (e.name !== 'AbortError') console.error(e);
});
function alPulsar(e) { if (e.target.dataset.accion === 'cerrar') destruir(); }
function alTeclado(e) { if (e.key === 'Escape') destruir(); }
function alRedimensionar() { /* ... */ }
function destruir() {
control.abort(); // una sola llamada deshace las cuatro categorias
raiz.remove(); // y el nodo sale del documento
}
return { destruir, raiz };
}
// Ejemplo ejecutable
const comp = crearComponente(document.body, ['uno', 'dos', 'tres']);
setTimeout(() => comp.destruir(), 3000);
La pieza que hace elegante este patrón es que los escuchas de eventos aceptan directamente una señal de aborto, lo que elimina la necesidad de guardar referencias a las funciones manejadoras para poder quitarlas después. Ese detalle, por sí solo, elimina una clase entera de errores: el de quitar un escucha con una función distinta de la que se registró, que es un fallo silencioso muy habitual con funciones enlazadas o flechas creadas al vuelo.
Y el mismo controlador cubre las otras tres categorías con dos líneas cada una, de modo que el componente entero tiene un único punto de limpieza. Si más adelante alguien añade otro registro, la pregunta “¿dónde va la limpieza?” tiene una respuesta obvia.
Hay un caso concreto que aparece en prácticamente todas las aplicaciones que renderizan HTML a mano y que merece ser nombrado porque su intuición es exactamente la contraria de lo que ocurre. Vaciar un contenedor asignando una cadena vacía a innerHTML parece una operación de limpieza total: en un instante desaparecen todos los hijos del documento y la pantalla lo confirma. Lo que en realidad ha ocurrido es que se han desconectado del árbol y nada más. Si cualquier parte de tu código guardaba una referencia a alguno de ellos —un elemento de formulario para leer su valor, una fila para actualizarla, un nodo para animarlo— ese subárbol entero sigue en memoria, ahora invisible y por tanto imposible de encontrar salvo con un snapshot. Y el patrón que hace de esto una fuga sistemática es el más común de todos en interfaces construidas sin framework: un bucle que renderiza filas, guarda referencias a algunas de ellas en un mapa para poder actualizarlas rápido, y que al refrescar vacía el contenedor y vuelve a renderizar. Cada refresco crea un árbol nuevo y deja el anterior colgando del mapa. Una tabla que se refresca cada diez segundos durante una jornada laboral acumula miles de árboles completos. Las tres reglas que cierran esta categoría son concretas. Primera: cualquier referencia a un nodo que guardes fuera del árbol necesita una política de invalidación explícita, y el momento de aplicarla es justo antes de destruir el árbol, no después. Segunda: si la referencia es solo para asociar datos a un nodo, usa un mapa débil, que resuelve el problema entero sin necesidad de acordarse de nada. Tercera, y la más efectiva a largo plazo: prefiere no guardar referencias a nodos en absoluto. Volver a consultar el DOM con un selector cuando hace falta es órdenes de magnitud más barato de lo que la gente cree —una consulta por identificador o por atributo de datos cuesta microsegundos— y elimina de raíz la posibilidad de que una referencia sobreviva a su nodo. La optimización de cachear elementos es una de las que peor relación coste-beneficio tiene en el desarrollo web moderno: ahorra un tiempo despreciable y crea la categoría de fuga más costosa que existe.