La fila 8 de la línea base de 09-01 dice +37,7 MB retenidos tras 200 filtrados. Es la más silenciosa de las diez y, a la larga, la más grave: un problema de velocidad se nota y se mide; un problema de memoria se manifiesta como «la pestaña se pone rara después de un rato» y como cierres inexplicables en móviles. Marta deja Nómada Tareas abierta toda la jornada; a las tres horas de filtrar, ordenar y marcar tareas, la aplicación consume más de un gigabyte y el navegador la mata. Esta lección explica cómo funciona la memoria en JavaScript —pila, montículo, referencias y el recolector de basura por alcanzabilidad—, cuáles son las cinco causas clásicas de fuga en el navegador con su ejemplo concreto en Nómada Tareas, cómo se diagnostica una fuga con el panel Memory de DevTools usando el patrón de las tres instantáneas, qué son y cuándo sirven de verdad WeakMap, WeakSet, WeakRef y FinalizationRegistry, y cómo se construye una limpieza sistemática con métodos destruir() y AbortController que hace que las fugas dejen de aparecer. Al terminar habrás encontrado y arreglado la fuga real de la vista.
Contenido
- Por qué la memoria importa en una aplicación de larga vida
- Pila, montículo y referencias
- El recolector de basura: alcanzabilidad
- Por qué no basta con contar referencias
- Generacional y marcado-barrido
- Qué es exactamente una fuga de memoria
- Las cinco causas clásicas
- Nodos desprendidos y por qué la delegación los evita
- Diagnóstico con DevTools: el panel Memory
- El patrón de las tres instantáneas
- Encontrar la fuga real de Nómada Tareas
- Arreglarla
- Referencias débiles:
WeakMap,WeakSet,WeakRefyFinalizationRegistry - Limpieza sistemática:
destruir()yAbortController - Detectar fugas en integración continua
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Por qué la memoria importa en una aplicación de larga vida
En una página web tradicional cada navegación descarta todo: si algo se filtra, se limpia al cambiar de página. Nómada Tareas es una aplicación de una sola página —tiene enrutador propio (vista/enrutador.js), service worker y estado persistente— y puede estar abierta ocho horas sin recargarse nunca. Ahí, una fuga pequeña se convierte en un problema grande por acumulación.
Los síntomas, en orden de aparición:
| Síntoma | Causa habitual |
|---|---|
| La aplicación va bien al abrir y mal tras un rato | Fuga acumulativa |
| Tirones periódicos cada pocos segundos | Recolecciones cada vez más caras |
| El desplazamiento se vuelve irregular | Montículo grande: cada recolección roba fotogramas |
| «Aw, Snap» / pestaña cerrada por el sistema | Límite de memoria alcanzado |
| Solo pasa en móviles | El límite ahí es mucho más bajo |
Y una precisión importante desde el principio: usar mucha memoria no es una fuga. Un tablero de 600 tareas ocupa lo que ocupa. Una fuga es memoria que la aplicación ya no necesita pero que no puede liberar. La diferencia se mide, y este capítulo enseña a medirla.
- Pila, montículo y referencias
JavaScript guarda los datos en dos sitios.
La pila (stack) es una estructura pequeña y rapidísima donde viven el contexto de ejecución de cada llamada (03-05) y los valores primitivos: números, cadenas cortas, booleanos, null, undefined, símbolos. Se libera sola al salir de la función: no hay nada que gestionar.
El montículo (heap) es una región grande donde viven los objetos: objetos literales, arrays, funciones, instancias de clase, nodos del DOM. Lo que guarda una variable no es el objeto, sino una referencia a su posición en el montículo.
let horas = 12; // primitivo: el valor está en la pila
let tarea = { id: 1, horas: 12 }; // objeto en el montículo; `tarea` guarda una referencia
let otra = tarea; // se copia la REFERENCIA, no el objeto
otra.horas = 20;
console.log(tarea.horas); // 20 ← es el mismo objeto (04-08)
tarea = null; // se suelta una referencia…
console.log(otra.horas); // 20 ← …pero `otra` lo mantiene vivoEse último bloque es toda la teoría que hace falta: un objeto vive mientras alguien pueda llegar hasta él. La pregunta relevante nunca es «¿he puesto esta variable a null?», sino «¿queda algún camino que lleve hasta este objeto?».
- El recolector de basura: alcanzabilidad
En C reservas y liberas a mano. En JavaScript hay un recolector de basura (garbage collector, GC) que libera automáticamente lo que ya no se puede alcanzar. Su criterio es la alcanzabilidad.
El motor mantiene un conjunto de raíces (roots): puntos de partida que siempre están vivos.
| Raíz | Ejemplo |
|---|---|
| El objeto global | window, globalThis |
| La pila de llamadas activa | Variables locales de las funciones en curso |
El árbol del DOM alcanzable desde document |
document.body y sus descendientes |
| Los módulos ES cargados y su estado | El const tablero de app.js (05-04) |
| Manejadores registrados y temporizadores activos | Lo que retiene un setInterval pendiente |
Un objeto es alcanzable si existe una cadena de referencias desde alguna raíz hasta él. Lo no alcanzable es basura y se libera. Punto.
flowchart LR
R(("Raíces")) --> A["app.js: tablero"]
A --> B["Tarea 1"]
A --> C["Tarea 2"]
R --> D["document"]
D --> E["div.tablero"]
E --> F["li[data-id='1']"]
G["Tarea antigua"] -.-> H["Objeto huérfano"]
style G fill:#fdd,stroke:#c00
style H fill:#fdd,stroke:#c00
En el diagrama, Tarea antigua y Objeto huérfano se referencian entre sí pero ninguna raíz llega hasta ellos: son basura, aunque tengan referencias. Esa isla es exactamente el caso que hunde a la estrategia del apartado siguiente.
- Por qué no basta con contar referencias
La estrategia más simple imaginable sería llevar en cada objeto un contador de cuántas referencias apuntan a él y liberarlo cuando llegue a cero. Es lo que hacían algunos sistemas antiguos, y no funciona, por un motivo concreto: los ciclos.
function crearCiclo() {
const tarea = { id: 1, titulo: 'Rediseñar la sala polivalente' };
const revision = { revisor: 'Marta' };
tarea.revision = revision; // tarea → revision
revision.tarea = tarea; // revision → tarea ← ciclo
return null; // nadie de fuera guarda ninguno de los dos
}
crearCiclo();
// Con conteo de referencias: cada uno tiene 1 referencia → nunca se liberan. FUGA.
// Con alcanzabilidad: ninguna raíz llega hasta ellos → basura. Se liberan.Los ciclos no son raros: aparecen en cuanto un nodo del DOM guarda una referencia a un objeto que a su vez guarda el nodo, en cualquier estructura padre-hijo con enlace hacia arriba, y en prácticamente cualquier grafo. Por eso todos los motores modernos usan alcanzabilidad, no conteo.
La consecuencia práctica es liberadora: no tienes que preocuparte por los ciclos. Lo que sí tienes que vigilar es lo contrario: referencias desde una raíz viva hacia algo que ya no necesitas. Un manejador en window, una entrada en un Map de módulo, un setInterval corriendo.
- Generacional y marcado-barrido
El algoritmo base es marcado y barrido (mark and sweep): partir de las raíces, marcar todo lo alcanzable, y barrer lo no marcado. Hacerlo sobre un montículo de cientos de megabytes en cada recolección sería carísimo, así que los motores añaden una observación estadística conocida como hipótesis generacional: la mayoría de los objetos mueren jóvenes.
De ahí la división del montículo:
flowchart TD
subgraph N["Generación joven (new space) · unos pocos MB"]
direction LR
A["Vivero"] -->|"sobrevive a una<br/>recolección menor"| B["Intermedio"]
end
B -->|"sobrevive a dos:<br/>promoción"| C
subgraph O["Generación vieja (old space) · cientos de MB"]
C["Objetos longevos:<br/>tablero, vista, módulos"]
end
N -.->|"recolección MENOR<br/>frecuente y muy rápida (~1 ms)"| N
O -.->|"recolección MAYOR<br/>rara y cara (decenas de ms)"| O
| Recolección menor (scavenge) | Recolección mayor (mark-compact) | |
|---|---|---|
| Zona | Generación joven | Todo el montículo |
| Frecuencia | Muy alta (varias por segundo) | Baja |
| Coste típico | < 1–2 ms | Decenas de ms, a veces más |
| Impacto visible | Ninguno | Puede robar fotogramas |
Tres consecuencias prácticas para el rendimiento:
- Crear muchos objetos efímeros es barato. El objeto que creas en un
mapy descartas enseguida muere en la generación joven y su recolección es casi gratis. Esto desmonta otro mito: «evita crear objetos en los bucles» es, en general, un consejo obsoleto. - Retener objetos es caro. Lo que sobrevive se promociona a la generación vieja, y esa sí exige recolecciones mayores caras. Una fuga no solo consume memoria: encarece todas las recolecciones futuras.
- Los motores modernos recolectan en paralelo y de forma incremental, así que las pausas son mucho menores que hace unos años. Pero no son cero, y en el carril Main del panel Performance aparecen como bloques grises etiquetados Minor GC / Major GC.
Un último apunte: no puedes forzar la recolección desde tu código. No existe ninguna API para ello (Chrome ofrece un botón de papelera en el panel Memory y la bandera --expose-gc en Node, y ambos son herramientas de diagnóstico, no de producción). Lo único que controlas es qué referencias mantienes.
- Qué es exactamente una fuga de memoria
Una fuga de memoria es memoria que la aplicación ya no necesita pero que sigue siendo alcanzable, y por tanto el recolector no puede liberar.
Las dos mitades importan. Si no es alcanzable, se libera y no hay fuga aunque el consumo sea alto. Si es alcanzable pero sí la necesitas, es uso legítimo. La fuga es la intersección: alcanzable e innecesario.
El diagnóstico se reduce siempre a la misma pregunta: ¿qué cadena de referencias, desde qué raíz, mantiene vivo esto? DevTools la responde literalmente, con un camino llamado retainers.
- Las cinco causas clásicas
Casi todas las fugas del navegador caen en cinco categorías. Vamos con las cinco, cada una con su caso en Nómada Tareas.
7.1 Variables globales accidentales
En modo no estricto, asignar a un identificador no declarado crea una propiedad de window (01-04). Y window es una raíz: lo que cuelgue de ahí no se libera jamás.
// ✗ Sin 'use strict' ni módulos, esto crea window.cacheTareas
function pintar(tareas) {
cacheTareas = tareas; // ← falta const/let. Adiós a 600 tareas, para siempre
}Nómada Tareas está a salvo casi por accidente: los módulos ES son estrictos por defecto (05-04), así que esa línea lanzaría ReferenceError. Y no-undef de ESLint (08-02) lo caza antes incluso de ejecutar. Pero la variante deliberada sigue siendo posible y es igual de dañina:
// ✗ Igual de malo, y perfectamente legal
window.__depuracionTablero = tablero; // «solo para depurar»…Ese window.__depuracionTablero, puesto un día para inspeccionar desde la consola y nunca retirado, mantiene vivo el tablero entero aunque la vista se destruya. Si necesitas algo así, ponlo detrás de una comprobación de entorno y quítalo en la limpieza.
7.2 Temporizadores nunca cancelados
Un setInterval pendiente es una raíz: mantiene viva su función y todo lo que su closure captura. El CanalTablero de 07-04 tiene un heartbeat con LATIDO_MS = 25000, y aquí está la versión con el fallo:
// ✗ js/datos/tiempo-real.js — versión con fuga
#alAbrir() {
this.#cambiarEstado(ESTADOS.CONECTADO);
// Cada reconexión arranca OTRO intervalo. Los anteriores siguen vivos.
this.#latido.intervalo = setInterval(() => {
this.#enviar({ tipo: 'ping' });
}, CanalTablero.LATIDO_MS);
}En una jornada con red inestable puede haber cuarenta reconexiones: cuarenta intervalos activos, cuarenta pings cada 25 segundos, cuarenta closures reteniendo el canal (y con él, todo lo que el canal referencia). Y además un error funcional: el servidor recibe cuarenta pings donde espera uno.
// ✓ Cancelar SIEMPRE antes de crear, y cancelar al cerrar
#pararLatido() {
clearInterval(this.#latido.intervalo);
clearTimeout(this.#latido.vigilante);
this.#latido.intervalo = null;
this.#latido.vigilante = null;
}
#alAbrir() {
this.#cambiarEstado(ESTADOS.CONECTADO);
this.#pararLatido(); // ← idempotente: seguro llamarlo siempre
this.#latido.intervalo = setInterval(() => {
this.#enviar({ tipo: 'ping' });
}, CanalTablero.LATIDO_MS);
}
#alCerrar() {
this.#pararLatido();
// …reconexión con retroceso exponencial, como en 07-04…
}La regla general: por cada setInterval debe existir un clearInterval en la ruta de limpieza, y la función que para debe ser idempotente para poder llamarla sin miedo.
setTimeout es menos peligroso porque se autolimita, pero un temporizador de 30 segundos retiene su closure durante 30 segundos aunque la vista ya se haya destruido, y su callback se ejecutará sobre un estado inexistente. Cancélalos también.
7.3 Manejadores sobre nodos eliminados
Esta es la que cierra el aviso de 06-05 sobre innerHTML = ''.
// ✗ Un manejador por tarjeta, y borrado con innerHTML
function pintarTodo(tareas) {
contenedor.innerHTML = ''; // los nodos se van del árbol…
for (const tarea of tareas) {
const li = crearTarjeta(tarea);
li.addEventListener('click', () => abrirDetalle(tarea)); // ← closure con la tarea
contenedor.append(li);
}
}La creencia extendida es que innerHTML = '' limpia los manejadores. No es cierto y no es falso: depende. El navegador libera un nodo eliminado y sus manejadores en cuanto el nodo deja de ser alcanzable. El problema es que la función manejadora es un closure que captura tarea, y si cualquier otra cosa sigue referenciando ese nodo —un array de nodos, un Map de caché, una variable de la consola, un observador— el nodo, el manejador, el closure y la tarea se quedan todos vivos, fuera del árbol pero en memoria. A eso se le llama nodo desprendido.
Con 600 tarjetas y 200 filtrados son hasta 120.000 nodos y 120.000 closures potencialmente retenidos. Ahí están buena parte de los 37,7 MB.
La solución de fondo no es limpiar mejor, sino no registrar 600 manejadores: es la delegación de 06-04.
7.4 Closures que retienen más de lo que parece
En 03-04 quedó dicho que un closure mantiene vivo el entorno donde nació, y allí se remitió a esta lección. Aquí está la parte incómoda: el closure no captura solo lo que usa; captura el entorno, y el motor solo puede descartar lo que demuestre que no se usa.
// ✗ El manejador solo necesita el id, pero el entorno tiene mucho más
function preparar(tareas) {
const todasLasTareas = tareas; // 600 objetos
const historico = cargarHistorico(); // 40.000 registros, 9 MB
const id = tareas[0].id;
document.addEventListener('tarea:cambiada', () => {
console.log(`Cambió algo mientras mirábamos ${id}`); // solo usa `id`
});
}Ese manejador vive en document, que es una raíz. Y aunque solo use id, el entorno donde nació contiene todasLasTareas e historico. Los motores hacen análisis para descartar variables no usadas, pero ese análisis tiene límites conocidos: basta que en el mismo ámbito haya un eval, un debugger, o otra función que sí use historico (todas las funciones de un mismo ámbito comparten el objeto de entorno) para que se retenga todo.
// ✓ Extraer solo lo necesario y crear el manejador en un ámbito pequeño
function preparar(tareas) {
const historico = cargarHistorico();
procesar(historico); // se usa aquí y se acaba
registrarAviso(tareas[0].id); // ← ámbito nuevo y mínimo
}
function registrarAviso(id) {
document.addEventListener('tarea:cambiada', () => {
console.log(`Cambió algo mientras mirábamos ${id}`);
});
}La regla: crea los closures de larga vida en el ámbito más pequeño posible, pasándoles solo los datos que necesitan.
7.5 Cachés que crecen sin límite
En 09-02 memoizaste formatearFecha y se dijo que la tercera condición era el límite de tamaño. Este es el motivo:
// ✗ Memoización sobre una clave ilimitada
const buscarMemoizado = memoizar((texto) => [...tablero].filter(
(t) => t.titulo.toLowerCase().includes(texto.toLowerCase())
));
// El usuario escribe: 's', 'se', 'ser', 'seri', ... una clave nueva por pulsación,
// y cada valor es un array con hasta 600 tareas. Crece para siempre.Con 200 búsquedas distintas y arrays de resultados, la caché sola puede pesar decenas de megabytes. Y memoizar usa un Map de módulo: una raíz viva.
Dos soluciones correctas. La primera, una caché con tope y política de expulsión (LRU, least recently used):
// js/util/cache-lru.js
/**
* Caché con tamaño máximo. Al llenarse expulsa la entrada usada hace más tiempo.
* Aprovecha que Map conserva el orden de inserción.
*/
export class CacheLRU {
#maximo;
#mapa = new Map();
constructor(maximo = 100) { this.#maximo = maximo; }
get(clave) {
if (!this.#mapa.has(clave)) return undefined;
const valor = this.#mapa.get(clave);
this.#mapa.delete(clave); // reinsertar la mueve al final: pasa a ser la más reciente
this.#mapa.set(clave, valor);
return valor;
}
set(clave, valor) {
if (this.#mapa.has(clave)) this.#mapa.delete(clave);
this.#mapa.set(clave, valor);
if (this.#mapa.size > this.#maximo) {
this.#mapa.delete(this.#mapa.keys().next().value); // la más antigua
}
}
get tamano() { return this.#mapa.size; }
limpiar() { this.#mapa.clear(); }
}La segunda, cuando la clave es un objeto, un WeakMap (apartado 13), que libera la entrada automáticamente cuando la clave deja de ser alcanzable.
Y aquí se cierra el aviso de 09-02 sobre el índice #porId del Tablero: si eliminar(id) quita del array pero no del Map, la tarea sigue alcanzable desde el índice. No es solo un error de datos: es una fuga.
- Nodos desprendidos y por qué la delegación los evita
Un nodo desprendido (detached DOM node) es un elemento que ya no está en el árbol del documento pero al que sigue apuntando alguien desde JavaScript. No se pinta, no se puede seleccionar con querySelector, no sirve para nada… y ocupa memoria, arrastrando además a todos sus descendientes.
// ✗ Guardar nodos en una estructura de larga vida
const nodosPorId = new Map();
function pintar(tareas) {
contenedor.replaceChildren();
for (const tarea of tareas) {
const li = crearTarjeta(tarea);
nodosPorId.set(tarea.id, li); // ← el Map retiene el nodo aunque salga del árbol
contenedor.append(li);
}
}Un detalle que sorprende a todo el mundo: basta retener un hijo para retener a todo el árbol al que pertenecía, porque cada nodo apunta a su parentNode. Guardar un <span> de una tarjeta puede retener la tarjeta entera, la lista y la columna.
La delegación de 06-04 elimina de raíz la causa más frecuente:
| Un manejador por tarjeta | Delegación en el contenedor | |
|---|---|---|
| Manejadores con 600 tareas | 600 (o 1.800, con tres acciones) | 1 |
| Closures retenidos | 600 | 1 |
| Al eliminar una tarjeta | Hay que retirar su manejador | No hay nada que retirar |
| Tarjetas creadas después | Hay que registrarlas | Funcionan solas |
| Riesgo de nodo desprendido | Alto | Prácticamente nulo |
Por eso el app.js de 06-06 registra un único click en .tablero y averigua la tarjeta con closest('[data-id]'). Aquello se justificó por claridad; ahora tiene también una justificación de memoria medible.
- Diagnóstico con DevTools: el panel Memory
Basta de teoría: vamos a mirar el montículo real. El panel Memory de DevTools ofrece tres herramientas, y cada una responde a una pregunta distinta.
| Herramienta | Pregunta que responde | Coste |
|---|---|---|
| Heap snapshot (instantánea del montículo) | ¿Qué hay vivo ahora y quién lo retiene? | Pausa la página unos segundos |
| Allocation instrumentation on timeline (asignaciones en el tiempo) | ¿Cuándo se asigna y qué sobrevive? | Alto: ralentiza mucho |
| Allocation sampling (muestreo) | ¿Qué función asigna más memoria? | Bajo: sirve en sesiones largas |
Además, en la pestaña Performance hay dos instrumentos complementarios: la casilla Memory, que dibuja las curvas de montículo de JS, nodos del DOM, oyentes de eventos y documentos a lo largo de la grabación, y la herramienta Detached Elements (en More tools), que lista directamente los nodos desprendidos.
Cómo leer una instantánea del montículo. Al abrirla ves una tabla de constructores con cuatro columnas:
| Columna | Significado |
|---|---|
| Constructor | Tipo del objeto: Tarea, Array, HTMLLIElement, (closure), system / Context |
| Distance | Distancia en saltos desde la raíz. Un número pequeño e inesperado es sospechoso |
| Shallow Size | Memoria del objeto en sí, sin lo que referencia |
| Retained Size | La importante: memoria que se liberaría si este objeto desapareciera |
Retained Size es la que señala al culpable. Un Map de 200 entradas tiene un shallow size ridículo, pero si cada entrada retiene un array de 600 tareas su retained size será de decenas de megabytes.
Y abajo, la sección Retainers: la cadena de quién referencia a quién hasta llegar a una raíz. Ese panel es literalmente la respuesta a «¿por qué sigue vivo esto?», y es donde acaba toda investigación de fugas.
Tres vistas seleccionables arriba a la izquierda:
- Summary: agrupado por constructor. La vista por defecto.
- Comparison: compara dos instantáneas y muestra los objetos creados y eliminados entre ambas, con
# New,# Deletedy# Delta. Es la vista que resuelve el caso. - Containment: el grafo completo desde las raíces. Útil para explorar a mano.
Un truco imprescindible: en el filtro de la vista Summary, escribe Detached. Aparecen todos los nodos desprendidos agrupados, con su tamaño retenido.
- El patrón de las tres instantáneas
Este es el procedimiento estándar para confirmar una fuga. Se llama de las tres instantáneas y funciona porque separa el ruido del crecimiento real.
flowchart LR
A["Abrir en incógnito<br/>y llegar al estado base"] --> B["Papelera:<br/>forzar recolección"]
B --> C["Instantánea 1"]
C --> D["Repetir la acción<br/>sospechosa N veces"]
D --> E["Volver al estado base"]
E --> F["Papelera"]
F --> G["Instantánea 2"]
G --> H["Repetir la acción<br/>otras N veces"]
H --> I["Volver al estado base<br/>+ papelera"]
I --> J["Instantánea 3"]
J --> K["Comparar 3 con 1"]
Las claves del método:
- Volver siempre al mismo estado antes de cada instantánea. Si acabas con un filtro puesto y antes no lo tenías, la diferencia no significa nada.
- Forzar la recolección con el icono de papelera antes de cada instantánea. Sin esto verás basura pendiente de recoger y creerás que hay fuga donde no la hay. (Tomar una instantánea ya fuerza una recolección, pero pulsar la papelera lo hace explícito y evita dudas.)
- Comparar la 3 con la 1, no la 2 con la 1. La primera ronda de una acción crea legítimamente estructuras que se reutilizarán después (cachés inicializadas, plantillas compiladas). Lo que aparece entre la 2 y la 3 es crecimiento puro, y comparar 3 contra 1 lo hace evidente.
- Si el número de objetos crece linealmente con las repeticiones, es una fuga. Si crece y se estabiliza, es una caché con tope: uso legítimo.
Y un procedimiento complementario mucho más barato para el primer vistazo, que además responde a «¿hay fuga, sí o no?» en un minuto:
- Pestaña Performance, casilla Memory marcada.
- Graba mientras repites la acción 20 veces.
- Mira la curva JS Heap: normal es un patrón en dientes de sierra que vuelve a la misma base. Preocupante es una sierra cuya base sube escalón a escalón.
- Mira las curvas Nodes y Listeners: si suben y no bajan, ya sabes de qué tipo es la fuga.
- Encontrar la fuga real de Nómada Tareas
Vamos al caso. Reproducimos el escenario de la línea base con un script en la consola, para que sea repetible:
// Consola: 200 filtrados sobre el tablero de 600 tareas
const RESPONSABLES = ['Iván', 'Lucía', 'Marta', null];
async function estresarFiltros(veces = 200) {
for (let i = 0; i < veces; i += 1) {
vista.actualizar({ filtros: { responsable: RESPONSABLES[i % 4], texto: `t${i % 7}` } });
await new Promise((r) => setTimeout(r, 0)); // deja respirar al navegador (09-02)
}
vista.actualizar({ filtros: { responsable: null, texto: '' } }); // volver al estado base
}Aplicando el patrón de las tres instantáneas:
| Tamaño del montículo | Nodos del DOM | Oyentes | Detached HTMLLIElement |
|
|---|---|---|---|---|
| Instantánea 1 (base) | 12,4 MB | 7.812 | 41 | 0 |
| Instantánea 2 (tras 200) | 31,8 MB | 7.812 | 241 | 20.914 |
| Instantánea 3 (tras 400) | 50,1 MB | 7.812 | 441 | 41.828 |
El veredicto es inmediato: crecimiento lineal. Cada tanda de 200 filtrados añade unos 19 MB, 200 oyentes y ~20.900 nodos desprendidos. No es una caché estabilizándose: es una fuga.
Ahora la vista Comparison entre la 3 y la 1, ordenada por Retained Size, con los sospechosos:
| Constructor | # New | # Deleted | # Delta | Retained (delta) |
|---|---|---|---|---|
Detached HTMLLIElement |
41.828 | 0 | +41.828 | 22,1 MB |
(closure) |
41.628 | 0 | +41.628 | 9,4 MB |
Array |
400 | 0 | +400 | 5,8 MB |
system / Context |
400 | 0 | +400 | 0,4 MB |
Y ahora la parte que resuelve el caso: seleccionar un Detached HTMLLIElement y leer sus retainers. La cadena es esta:
Detached HTMLLIElement
└─ value in Map ← lo retiene un Map
└─ #nodosPorId in TableroVista
└─ vista in app.js (módulo) ← raízY para uno de los (closure):
Dos fugas distintas, encontradas en dos minutos. Vamos a mirar el código culpable.
// ✗ js/vista/tablero-vista.js — el código con las dos fugas
export class TableroVista {
#contenedor; #resumen; #estado;
#nodosPorId = new Map(); // «índice para acelerar la reconciliación» (09-02)
render() {
const visibles = this.#visibles();
// FUGA 2: un manejador NUEVO en cada render, sobre window, que además
// captura `visibles` (hasta 600 tareas) en su closure
window.addEventListener('resize', () => this.#ajustarAlturas(visibles));
for (const { estado } of COLUMNAS) {
const columna = $(`.columna[data-estado="${estado}"]`, this.#contenedor);
const delEstado = porEstado[estado] ?? [];
reconciliar(
$('.lista-tareas', columna),
delEstado,
(t) => t.id,
(t, nodo) => {
this.#nodosPorId.set(t.id, nodo); // FUGA 1: se añade… y nunca se quita
return pintarTarjeta(t, nodo, this.#estado.hoy);
}
);
}
}
}Ambos fallos son completamente razonables vistos por separado, y ese es el mensaje de este apartado. El Map se añadió siguiendo el consejo de 09-02 de indexar por clave. El addEventListener se puso donde parecía natural, junto al código que lo necesita. Ninguno de los dos se ve en una revisión de código, ninguno rompe ninguna de las 124 pruebas, y con seis tareas ninguno se nota. Las fugas no se encuentran leyendo: se encuentran midiendo.
- Arreglarla
Las dos correcciones, con su razón:
// ✓ js/vista/tablero-vista.js
export class TableroVista {
#contenedor; #resumen; #estado;
#nodosPorId = new Map();
#controlador = new AbortController(); // gobierna TODOS los manejadores de la vista
constructor({ contenedor, resumen, tablero, hoy = HOY }) {
this.#contenedor = contenedor;
this.#resumen = resumen;
this.#estado = { tablero, hoy, filtros: { responsable: null, texto: '' }, orden: 'prioridad' };
this.#prepararColumnas();
// ARREGLO 2: se registra UNA vez, en el constructor, con signal para poder retirarlo.
// Y no captura `visibles`: lee el estado actual cuando se ejecuta.
window.addEventListener('resize', this.#alRedimensionar, {
signal: this.#controlador.signal,
passive: true
});
}
// Campo de clase con función flecha: `this` correcto y misma referencia siempre (04-02)
#alRedimensionar = throttle(() => this.#ajustarAlturas(this.#visibles()), 100);
render() {
const visibles = this.#visibles();
const porEstado = Object.groupBy(visibles, (t) => t.estado);
const vivos = new Set(visibles.map((t) => t.id)); // ARREGLO 1, parte A
for (const { estado } of COLUMNAS) {
const columna = $(`.columna[data-estado="${estado}"]`, this.#contenedor);
const delEstado = porEstado[estado] ?? [];
reconciliar($('.lista-tareas', columna), delEstado, (t) => t.id, (t, nodo) => {
this.#nodosPorId.set(t.id, nodo);
return pintarTarjeta(t, nodo, this.#estado.hoy);
});
}
// ARREGLO 1, parte B: purgar del índice lo que ya no está en el árbol
for (const id of this.#nodosPorId.keys()) {
if (!vivos.has(id)) this.#nodosPorId.delete(id);
}
// …resumen y evento, igual que en 06-06…
}
/**
* Libera todo lo que esta vista retiene fuera de sí misma.
* Sin esto, destruir la vista no basta: window sigue apuntando a sus manejadores.
*/
destruir() {
this.#controlador.abort(); // retira TODOS los manejadores registrados con signal
this.#alRedimensionar.cancel?.();
this.#nodosPorId.clear();
this.#contenedor.replaceChildren();
this.#estado = null;
}
}Cuatro puntos que merecen atención:
{ signal }enaddEventListeneres la herramienta clave de la lección: un soloabort()retira todos los manejadores registrados con esa señal, por muchos que sean y estén donde estén. Es el mismoAbortControllerque usaste en 07-03 para cancelar peticiones, aplicado a eventos (06-04).- El manejador es un campo de clase con función flecha, no una flecha creada en el sitio. Eso garantiza que la referencia es siempre la misma, requisito imprescindible si algún día quisieras retirarlo con
removeEventListener. - El manejador ya no captura
visibles. Lee el estado actual al ejecutarse. Un closure de larga vida no debe congelar datos grandes. - La purga del índice es la aplicación literal del aviso de 09-02: mantener un índice obliga a mantenerlo en las dos direcciones.
Y la medición, rehaciendo el patrón de las tres instantáneas:
| Medida | Antes | Después |
|---|---|---|
| Montículo tras 400 filtrados | 50,1 MB | 12,8 MB |
| Crecimiento retenido | +37,7 MB | +0,4 MB |
Detached HTMLLIElement |
41.828 | 0 |
| Oyentes de eventos | 441 | 41 |
| Pausas de recolección mayor en 60 s | 14 (máx. 84 ms) | 3 (máx. 11 ms) |
Esa última fila es el beneficio que nadie espera: arreglar la fuga también ha hecho la aplicación más rápida, porque un montículo pequeño se recolecta rápido. La fila 8 de la línea base está resuelta.
- Referencias débiles:
WeakMap, WeakSet, WeakRef y FinalizationRegistry
WeakMap, WeakSet, WeakRef y FinalizationRegistryJavaScript ofrece cuatro mecanismos para referenciar sin retener. Antes de nada, la advertencia honesta: el 95 % del código correcto no necesita ninguno de los cuatro. La solución a una fuga es casi siempre limpiar bien, no usar referencias débiles. Pero hay un caso donde WeakMap es exactamente la herramienta adecuada.
| Herramienta | Qué guarda | Se libera cuando… | Iterable | Uso realista |
|---|---|---|---|---|
WeakMap |
Clave objeto → valor cualquiera | La clave deja de ser alcanzable | No | Metadatos asociados a objetos o nodos del DOM |
WeakSet |
Objetos | El objeto deja de ser alcanzable | No | Marcar objetos ya procesados |
WeakRef |
Una referencia suelta | El objetivo deja de ser alcanzable | — | Cachés muy grandes. Raro |
FinalizationRegistry |
Un callback de limpieza | Tras recolectar el objetivo | — | Liberar recursos externos. Muy raro |
Que WeakMap y WeakSet no sean iterables no es un descuido: si pudieras recorrerlos, el resultado dependería de cuándo haya pasado el recolector, y tu programa sería no determinista. La imposibilidad de iterar es lo que hace que la semántica sea segura.
El caso legítimo en Nómada Tareas: asociar datos a nodos del DOM sin retenerlos.
// js/vista/tarjeta.js
// Clave: el nodo. Valor: metadatos que no queremos meter en dataset (04-01)
const metadatos = new WeakMap();
export function crearTarjeta(tarea, hoy) {
const li = crearElemento('li', { class: 'tarjeta', 'data-id': String(tarea.id) });
metadatos.set(li, {
pintadaEn: performance.now(),
versionDatos: tarea.version,
alturaCalculada: null
});
return li;
}
export function metadatosDe(nodo) {
return metadatos.get(nodo) ?? null;
}La diferencia es exactamente la fuga del apartado 11: con un Map normal, cada nodo pintado quedaría retenido para siempre por el mapa. Con WeakMap, cuando el nodo sale del árbol y nadie más lo referencia, la entrada desaparece sola. Es la única estructura que da caché sin obligación de purga.
// Demostración conceptual del contraste
const fuerte = new Map();
const debil = new WeakMap();
let nodo = document.createElement('li');
fuerte.set(nodo, 'metadatos');
debil.set(nodo, 'metadatos');
nodo = null;
// `fuerte` sigue reteniendo el nodo: FUGA.
// `debil` no lo retiene: en la próxima recolección, la entrada desaparece.Y su límite, que hay que conocer: WeakMap solo sirve cuando la clave es el objeto cuya vida manda. Para memoizar formatearFecha, cuya clave es la cadena '2026-09-05', WeakMap no vale (las cadenas no pueden ser claves débiles): ahí la respuesta es la CacheLRU del apartado 7.5.
WeakRef y FinalizationRegistry existen, y conviene reconocerlos, pero la especificación misma desaconseja depender de ellos: no hay ninguna garantía de cuándo —ni de si— se ejecutará la finalización. Nunca pongas ahí lógica necesaria para la corrección de tu programa.
// Ejemplo ilustrativo, NO recomendado como patrón habitual
const registro = new FinalizationRegistry((etiqueta) => {
console.log(`Recolectado: ${etiqueta}`); // puede no ejecutarse NUNCA
});
registro.register(new Worker(url), 'worker de planificación');
- Limpieza sistemática:
destruir() y AbortController
destruir() y AbortControllerLa lección estructural de todo lo anterior: cualquier objeto que registre algo fuera de sí mismo necesita una forma de deshacerlo. Manejadores en window o document, temporizadores, observadores, sockets, workers, suscripciones. Ese contrato se llama destruir(), y conviene que sea sistemático en todo el proyecto.
| Lo que registra | Cómo se deshace |
|---|---|
addEventListener |
removeEventListener o, mejor, { signal } + abort() |
setInterval / setTimeout |
clearInterval / clearTimeout |
IntersectionObserver, ResizeObserver, MutationObserver |
.disconnect() |
WebSocket |
.close() |
Worker |
.terminate() |
fetch en curso |
AbortController.abort() (07-03) |
requestAnimationFrame |
cancelAnimationFrame |
| Entradas en cachés y mapas propios | .clear() |
Aplicado al CanalTablero de 07-04, que es la clase con más recursos externos del proyecto:
// js/datos/tiempo-real.js
export class CanalTablero extends EventTarget {
#controlador = new AbortController();
conectar() {
if (this.#controlador.signal.aborted) throw new Error('Canal destruido');
this.#socket = new WebSocket(this.#url);
const { signal } = this.#controlador;
this.#socket.addEventListener('open', () => this.#alAbrir(), { signal });
this.#socket.addEventListener('message', (e) => this.#alMensaje(e), { signal });
this.#socket.addEventListener('close', () => this.#alCerrar(), { signal });
this.#socket.addEventListener('error', () => this.#alError(), { signal });
// Incluso los manejadores de window quedan gobernados por la misma señal
window.addEventListener('online', () => this.conectar(), { signal });
}
/** Libera hilo, socket, temporizadores y manejadores. Idempotente. */
destruir() {
this.#cerradoAPropósito = true;
this.#pararLatido(); // clearInterval + clearTimeout
clearTimeout(this.#reintentoId);
this.#socket?.close(1000, 'destruido');
this.#socket = null;
this.#cola.length = 0;
this.#controlador.abort(); // ← retira TODO de golpe
}
}Y el cableado en app.js, con el punto de limpieza natural de una aplicación de una sola página:
// js/app.js
const vista = new TableroVista({ /* … */ });
const canal = new CanalTablero(URL_WS);
const planificador = new Planificador();
function destruirTodo() {
vista.destruir();
canal.destruir();
planificador.destruir();
}
// Al cambiar de ruta (vista/enrutador.js) y al descargar la página
window.addEventListener('pagehide', destruirTodo);Se usa pagehide y no unload deliberadamente: unload impide que la página entre en la caché de retroceso (bfcache), lo que empeora el rendimiento de la navegación hacia atrás. Es un caso bonito de dos objetivos en tensión, y pagehide los resuelve.
Y como toda decisión de diseño, se prueba (08-03):
// test/vista/tablero-vista-destruir.test.js
test('destruir() retira todos los manejadores de window', () => {
const registrar = jest.spyOn(window, 'addEventListener');
const vista = new TableroVista({ contenedor, resumen, tablero, hoy: HOY });
expect(registrar).toHaveBeenCalledTimes(1);
vista.destruir();
window.dispatchEvent(new Event('resize')); // no debe hacer nada ni lanzar
expect(() => vista.render()).toThrow(); // la vista está destruida
});
test('render() purga del índice las tareas que ya no se muestran', () => {
const vista = new TableroVista({ contenedor, resumen, tablero, hoy: HOY });
vista.render();
expect(vista.nodosIndexados).toBe(6);
vista.actualizar({ filtros: { responsable: 'Lucía' } }); // solo 1 tarea de Lucía
expect(vista.nodosIndexados).toBe(1); // ← el resto, purgado
});
- Detectar fugas en integración continua
Una fuga arreglada vuelve en tres meses si nadie la vigila. Hay dos niveles de defensa automática, y conviene tener los dos.
Nivel 1: pruebas de invariantes en Jest. Baratas, rápidas y no miden memoria, sino las estructuras que la retienen. Son las que de verdad se ejecutan en cada commit.
// test/vista/no-fugas.test.js
test('200 filtrados no acumulan manejadores ni entradas de índice', () => {
const espia = jest.spyOn(window, 'addEventListener');
const vista = new TableroVista({ contenedor, resumen, tablero: tableroGrande, hoy: HOY });
const trasConstruir = espia.mock.calls.length;
for (let i = 0; i < 200; i += 1) {
vista.actualizar({ filtros: { responsable: ['Iván', 'Lucía', 'Marta', null][i % 4] } });
}
expect(espia.mock.calls.length).toBe(trasConstruir); // ni uno más
expect(vista.nodosIndexados).toBeLessThanOrEqual(600); // el índice no crece sin fin
expect(document.querySelectorAll('li.tarjeta').length).toBeLessThanOrEqual(600);
});
test('el canal no acumula intervalos al reconectar', () => {
jest.useFakeTimers();
const canal = new CanalTablero('ws://ejemplo');
const crear = jest.spyOn(global, 'setInterval');
const limpiar = jest.spyOn(global, 'clearInterval');
for (let i = 0; i < 10; i += 1) { canal.simularApertura(); canal.simularCierre(); }
expect(crear).toHaveBeenCalledTimes(10);
expect(limpiar).toHaveBeenCalledTimes(20); // se limpia al abrir y al cerrar
canal.destruir();
});Nivel 2: medición real del montículo con Puppeteer, en un trabajo nocturno (es lento y ruidoso para cada commit):
// scripts/medir-fuga.mjs
import puppeteer from 'puppeteer';
const navegador = await puppeteer.launch({ args: ['--js-flags=--expose-gc'] });
const pagina = await navegador.newPage();
await pagina.goto('http://localhost:4173');
await pagina.waitForSelector('li.tarjeta');
const medir = async () => {
await pagina.evaluate(() => globalThis.gc?.()); // forzar recolección
return (await pagina.metrics()).JSHeapUsedSize;
};
const base = await medir();
await pagina.evaluate(() => estresarFiltros(200));
const despues = await medir();
const crecimientoMB = (despues - base) / 1024 / 1024;
console.log(`Crecimiento tras 200 filtrados: ${crecimientoMB.toFixed(1)} MB`);
await navegador.close();
if (crecimientoMB > 2) { // umbral con margen para el ruido
console.error('Posible fuga de memoria: el montículo crece más de lo esperado');
process.exit(1);
}Dos avisos sobre este script. El umbral debe tener margen generoso (2 MB, no 0,1 MB): la memoria en un ejecutor compartido es ruidosa, y un presupuesto que falla en falso se desactiva a la semana. Y --expose-gc es imprescindible: sin forzar la recolección medirías basura pendiente y el resultado no significaría nada.
Errores Comunes y Consejos
- Creer que asignar
nulllibera memoria. Solo suelta una referencia. Si queda otro camino desde una raíz, el objeto sigue vivo. - Creer que
innerHTML = ''limpia los manejadores. Los libera solo si nadie más referencia los nodos. Si unMap, un array o un observador los retiene, tienes nodos desprendidos. - Guardar nodos del DOM en estructuras de larga vida. Es la causa número uno de nodos desprendidos. Y retener un hijo retiene todo su árbol por
parentNode. - Registrar manejadores dentro de
render(). Cada render añade uno más. Regístralos en el constructor, una vez. setIntervalsinclearInterval. Una raíz permanente que retiene su closure entero.- Reconectar sin parar el temporizador anterior. Cuarenta reconexiones, cuarenta heartbeats.
- Cachés sin tope. Un
Mapcon clave de texto libre crece indefinidamente. Pon un límite (LRU) o usaWeakMapsi la clave es un objeto. - Índices no sincronizados. Si
eliminar()quita del array y no delMap, tienes una fuga y un error de datos. - Closures de larga vida creados en ámbitos grandes. Capturan todo el entorno, no solo lo que usan.
- Medir sin forzar la recolección. Verás basura pendiente y diagnosticarás una fuga inexistente.
- Comparar la instantánea 2 con la 1. La primera ronda crea estructuras legítimas. Compara la 3 con la 1.
- Medir con extensiones instaladas. Inyectan nodos y manejadores en tu página. Usa incógnito.
- Confiar en
FinalizationRegistrypara liberar recursos. No hay garantía de que se ejecute. Nunca pongas ahí lógica necesaria. - Usar
unloadpara limpiar. Impide la caché de retroceso y empeora la navegación. Usapagehide. - Consejo: cada clase que registre algo fuera de sí misma, con su
destruir(). Y que sea idempotente. - Consejo: un
AbortControllerpor objeto de larga vida. Unabort()retira decenas de manejadores de golpe: es la mejor herramienta de limpieza que existe hoy. - Consejo: la delegación de eventos no es solo elegancia. Un manejador en vez de 600 elimina de raíz una familia entera de fugas.
- Consejo: mira la curva de nodos y oyentes en Performance antes de tomar instantáneas. En un minuto sabes si hay fuga y de qué tipo.
Ejercicios
Ejercicio 1 — Auditoría de retenciones. Para cada fragmento, di si hay fuga, qué raíz retiene qué, y escribe la corrección.
// (a)
const historial = [];
document.addEventListener('tarea:cambiada', (e) => {
historial.push({ cuando: Date.now(), tarjeta: e.target.closest('li'), tablero: [...tablero] });
});
// (b)
function iniciarSincronizacion(canal) {
setInterval(() => canal.enviar({ tipo: 'sync' }), 30000);
}
// (c)
const alturas = new WeakMap();
function medir(nodo) {
alturas.set(nodo, nodo.offsetHeight);
}
// (d)
class Editor {
constructor(nodo) {
this.nodo = nodo;
this.observador = new ResizeObserver(() => this.ajustar());
this.observador.observe(nodo);
}
cerrar() { this.nodo.remove(); }
}Ejercicio 2 — Confirmar una fuga con el método. Iván sospecha que abrir y cerrar el panel de detalle de una tarea filtra memoria. Escribe el procedimiento completo que seguirías: el script de estrés, los pasos con el panel Memory, qué mirarías en cada instantánea, qué resultado confirmaría la fuga y cuál la descartaría. Después indica qué dos causas de las cinco clásicas serían las más probables en un panel modal, y por qué.
Ejercicio 3 — Map o WeakMap. Para cada caché, decide cuál usar y justifica. Si ninguna sirve, propón la alternativa.
- Altura calculada de cada tarjeta, indexada por nodo
<li>. - Resultado de
formatearFecha(iso), indexado por la cadena ISO. - Índice
id → TareadeTablero. - Últimos 50 resultados de búsqueda, indexados por texto buscado.
- Marcar qué tareas ya se han enviado al servidor, indexado por instancia de
Tarea.
Soluciones
Solución 1
(a) Fuga grave. Tres a la vez. document es raíz → retiene el manejador → el closure retiene historial → cada entrada retiene un nodo del DOM (que quedará desprendido tras el siguiente render) y una copia completa del tablero (600 tareas). Con 200 cambios, 200 copias de 600 tareas.
// ✓ Guardar datos, no nodos ni estructuras completas; y limitar el tamaño
const historial = [];
const MAX_HISTORIAL = 100;
document.addEventListener('tarea:cambiada', (e) => {
historial.push({
cuando: Date.now(),
id: Number(e.target.closest('li')?.dataset.id), // ← el id, no el nodo
total: tablero.resumen(HOY).total // ← un número, no una copia
});
if (historial.length > MAX_HISTORIAL) historial.shift();
}, { signal: controlador.signal });(b) Fuga. El setInterval no se guarda: no hay forma de cancelarlo. Retiene canal para siempre, aunque el canal se destruya.
// ✓ Devolver la forma de cancelar
function iniciarSincronizacion(canal) {
const id = setInterval(() => canal.enviar({ tipo: 'sync' }), 30000);
return () => clearInterval(id); // se llama desde canal.destruir()
}(c) No hay fuga. WeakMap no retiene sus claves: cuando el nodo sale del árbol y nadie más lo referencia, la entrada desaparece sola. Es el uso canónico. (Nota aparte: offsetHeight fuerza un cálculo de estilo síncrono, y eso es un problema de rendimiento distinto que verás en 09-04.)
(d) Fuga. cerrar() quita el nodo del árbol pero no desconecta el ResizeObserver. El observador retiene el nodo y el Editor entero, y el nodo queda desprendido.
// ✓
class Editor {
#controlador = new AbortController();
constructor(nodo) {
this.nodo = nodo;
this.observador = new ResizeObserver(() => this.ajustar());
this.observador.observe(nodo);
nodo.addEventListener('keydown', (e) => this.#atajo(e), { signal: this.#controlador.signal });
}
cerrar() {
this.observador.disconnect(); // ← imprescindible
this.#controlador.abort();
this.nodo.remove();
this.nodo = null;
}
}Solución 2
Script de estrés (repetible, con vuelta al estado base y cesión al navegador):
async function estresarDetalle(veces = 100) {
for (let i = 0; i < veces; i += 1) {
document.querySelector(`li[data-id="${(i % 600) + 1}"] [data-accion="detalle"]`).click();
await new Promise((r) => setTimeout(r, 20)); // que el modal se monte
document.querySelector('.modal__cerrar').click();
await new Promise((r) => setTimeout(r, 20)); // que se desmonte
}
}Procedimiento:
- Ventana de incógnito, sin extensiones. Cargar la aplicación y esperar al render inicial.
- Panel Performance con la casilla Memory: grabar 20 iteraciones y mirar las curvas JS Heap, Nodes y Listeners. Si la base de la sierra sube escalón a escalón, hay fuga y ya sabemos si es de nodos o de oyentes. Este paso cuesta un minuto y suele bastar para decidir si merece la pena seguir.
- Panel Memory → papelera → Instantánea 1.
await estresarDetalle(100)→ volver al estado base → papelera → Instantánea 2.await estresarDetalle(100)otra vez → estado base → papelera → Instantánea 3.- Seleccionar la 3, vista Comparison contra la 1, ordenar por Retained Size.
- Filtrar por
Detacheden Summary y mirar los retainers de un nodo desprendido.
Qué confirma la fuga: que los contadores crezcan linealmente —si la 2 tiene +100 modales y la 3 tiene +200, es fuga—, que aparezcan Detached HTMLDivElement en cantidad proporcional a las repeticiones, o que el número de oyentes suba en 100 por tanda. Qué la descarta: que la 3 sea prácticamente igual que la 2 (crecimiento que se estabiliza: una caché con tope, uso legítimo) o que el delta oscile sin tendencia (ruido).
Las dos causas más probables en un modal son, por este orden: manejadores no retirados —un modal registra casi siempre keydown en document para cerrar con Escape, y un click en el fondo; si cerrar() quita el nodo pero no retira esos manejadores de document, cada apertura deja uno más y cada uno retiene el modal entero, que queda desprendido— y temporizadores no cancelados, porque los modales suelen usar un setTimeout para la animación de salida o para devolver el foco, y si se cierra antes de que dispare, el temporizador sigue reteniendo el nodo. Ambas se resuelven con el mismo patrón: un AbortController por modal y un cerrar() que llame a abort() y a clearTimeout().
Solución 3
| # | Caché | Elección | Justificación |
|---|---|---|---|
| 1 | Altura por nodo <li> |
WeakMap |
La clave es un objeto cuya vida debe mandar. Cuando la tarjeta sale del árbol, la entrada se va sola: cero purga, cero fugas. Uso canónico |
| 2 | formatearFecha(iso) |
Map (o CacheLRU) |
La clave es una cadena: WeakMap no la admite. Las claves están acotadas (~200 fechas distintas), así que un Map normal basta; si el rango fuera ilimitado, CacheLRU con tope |
| 3 | Índice id → Tarea |
Map, con purga explícita |
Aquí la retención es deseada: el tablero debe mantener vivas sus tareas. WeakMap ni siquiera funcionaría, porque la clave es un número. La obligación es sincronizar eliminar() con delete |
| 4 | Últimos 50 resultados | CacheLRU(50) |
Clave de texto libre, es decir, ilimitada: un Map crecería sin fin (apartado 7.5) y cada valor puede ser un array de 600 tareas. El tope es imprescindible |
| 5 | Tareas ya enviadas | WeakSet |
La clave es un objeto y solo interesa la pertenencia, no un valor asociado. Si la tarea se elimina del tablero, su marca desaparece sola, que es justo lo correcto |
La regla que resume la tabla: WeakMap/WeakSet cuando la clave es un objeto y su vida debe gobernar la de la entrada; Map cuando la retención es deliberada; CacheLRU cuando la clave es un primitivo de dominio ilimitado.
Conclusión
La fila 8 de la línea base está cerrada: de +37,7 MB retenidos y 41.828 nodos desprendidos a +0,4 MB y cero, con la ventaja añadida de que las pausas de recolección mayor han bajado de 14 (hasta 84 ms) a 3 (hasta 11 ms) por minuto. Arreglar la fuga ha hecho la aplicación más rápida además de más estable.
Entiendes el modelo: la pila para primitivos y contextos, el montículo para objetos, y variables que guardan referencias, no objetos. Sabes que el recolector decide por alcanzabilidad desde un conjunto de raíces —el objeto global, la pila activa, el DOM alcanzable, el estado de los módulos, los manejadores y temporizadores registrados—, y sabes por qué contar referencias no basta: dos objetos que se apuntan mutuamente formarían una isla inmortal, y los ciclos están por todas partes. Conoces la organización generacional —vivero barato para lo efímero, generación vieja cara para lo longevo— y las dos consecuencias que de ahí se derivan: crear objetos temporales es barato, y retener es caro dos veces, en memoria y en coste de recolección.
Tienes las cinco causas clásicas con su caso real: globales accidentales (de las que te salvan los módulos ES y no-undef, salvo el window.__depuracion puesto «solo un momento»), temporizadores nunca cancelados como el heartbeat del CanalTablero que se duplicaba en cada reconexión, manejadores sobre nodos eliminados, que cierra el aviso de 06-05 sobre innerHTML = '' explicando que el problema no es el borrado sino quién más apunta al nodo, closures que retienen el entorno entero cerrando el aviso de 03-04, y cachés que crecen sin límite, resueltas con CacheLRU. Y sabes qué es un nodo desprendido, por qué retener un solo hijo arrastra todo su árbol, y por qué la delegación de eventos de 06-04 elimina de raíz una familia entera de fugas: un manejador en lugar de 600.
Sabes diagnosticar: el panel Memory con sus tres herramientas, la lectura de una instantánea por Retained Size y sobre todo por retainers —la cadena literal que responde «por qué sigue vivo esto»—, el filtro Detached, la vista Comparison, las curvas de montículo, nodos y oyentes en Performance, y el patrón de las tres instantáneas con sus cuatro reglas: mismo estado base, forzar recolección, comparar la 3 con la 1, y buscar crecimiento lineal. Con ese método encontraste dos fugas reales en dos minutos —un Map de nodos sin purgar y un addEventListener dentro de render()—, ninguna de las cuales rompía ninguna de las 124 pruebas ni se veía en una revisión de código.
Sabes cuándo tocan las referencias débiles: WeakMap para metadatos asociados a nodos, WeakSet para marcar objetos, y la advertencia clara de que WeakRef y FinalizationRegistry no son de uso corriente y jamás deben sostener lógica necesaria. Y tienes el hábito que hace que las fugas dejen de aparecer: un método destruir() idempotente en todo objeto que registre algo fuera de sí mismo, y un AbortController con signal que retira decenas de manejadores con un solo abort() —el mismo controlador de 07-03, aquí aplicado a eventos—, con pagehide en vez de unload para no romper la caché de retroceso. Todo ello vigilado en integración continua por pruebas de invariantes baratas en Jest y una medición nocturna del montículo con Puppeteer y umbrales con margen.
Quedan dos frentes, y el siguiente es el que domina la tabla. La fila 5 sigue en 310 ms por render y la fila 9 en 7.812 nodos: sabemos por el Bottom-Up de 09-01 que 600 llamadas a pintarTarjeta cuestan 186 ms de tiempo propio y que hay 41 recálculos de estilo donde debería haber uno. Ese trabajo no es JavaScript lento —ya lo has optimizado— ni memoria retenida: es el coste de hablar con el DOM, un coste que tiene reglas propias, un canal de comunicación caro y un pipeline de renderizado que se puede sabotear sin darse cuenta con una sola línea que lea offsetHeight dentro de un bucle. Es la deuda pendiente desde 06-02 y 06-05, y le toca ahora: Manipulación Eficiente del DOM.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
