wandres.dev
MEMORY II · Cazar una fuga

Escuchas que nunca se quitan y otras suscripciones huérfanas

La familia de fugas por registros que sobreviven a su dueño, cómo se auditan desde la consola, los cinco tipos de suscripción que hay que vigilar, y el error de la función distinta.

⏱ 18 min

Si tuvieras que apostar por una sola causa de fuga en una aplicación web sin saber nada más de ella, la apuesta correcta es un registro que sobrevivió a lo que registraba. Escuchas de eventos, observadores, suscripciones a un almacén, temporizadores, conexiones abiertas: cinco mecanismos distintos con la misma estructura y el mismo fallo. Esta lección es el catálogo de los cinco, la auditoría que los encuentra, y el error de sintaxis que hace que muchas limpiezas escritas de buena fe no limpien nada.

🎯 Al terminar esta lección sabrás
  • Auditar los escuchas registrados en un elemento y en objetos globales.
  • Reconocer los cinco tipos de suscripción y su mecanismo de baja.
  • Diagnosticar el error de quitar un escucha con una función distinta.
  • Verificar que una limpieza funciona de verdad.

Por qué un escucha retiene tanto

Registrar un escucha crea una referencia desde el objetivo hacia la función manejadora. Si el objetivo es el documento o la ventana —que viven toda la sesión— esa función vive toda la sesión.

Y una función mantiene vivo todo su ámbito capturado. Si el manejador se definió dentro de un componente y usa cualquier variable de ese componente, mantiene vivo el objeto del contexto, que a su vez mantiene vivo todo lo que ese contexto contenía: la instancia, sus datos, sus nodos, las respuestas que guardó.

Un solo escucha olvidado en el documento puede por tanto retener un componente entero con sus megabytes. Y como se registra uno por cada vez que el componente se monta, en una sesión larga hay decenas.

⚠️
Cuidado

Un escucha registrado en un elemento que se quita del documento se libera junto con él, siempre que nadie más referencie ni al elemento ni al manejador. Ese caso no fuga. Los que fugan son los registrados en objetivos de vida larga: el documento, la ventana, el objeto global, un elemento contenedor que sobrevive, o un emisor propio de la aplicación.

Auditar lo registrado

La consola tiene una utilidad que enumera los escuchas de un elemento y se trató en inspeccionar eventos. Aquí interesa el uso agregado: contar cuántos hay en los objetivos de vida larga y ver si crecen.

// Auditoria de escuchas en los objetivos de vida larga
// getEventListeners solo existe en la consola de las DevTools
(() => {
  const objetivos = [window, document, document.body, document.documentElement];
  const filas = [];
  for (const o of objetivos) {
    let mapa;
    try { mapa = getEventListeners(o); } catch { console.warn('Ejecutalo en la consola de DevTools.'); return; }
    for (const [tipo, lista] of Object.entries(mapa)) {
      filas.push({
        objetivo: o === window ? 'window' : (o.nodeName || String(o)).toLowerCase(),
        evento: tipo,
        cuantos: lista.length,
        pasivos: lista.filter(l => l.passive).length,
        unaVez: lista.filter(l => l.once).length
      });
    }
  }
  filas.sort((a, b) => b.cuantos - a.cuantos);
  console.table(filas);
  const total = filas.reduce((s, f) => s + f.cuantos, 0);
  console.log('Total de escuchas en objetivos de vida larga:', total);
  console.log('Repite esta medida tras varios ciclos de tu aplicacion.');
  console.log('Si el numero crece, hay registros que no se estan quitando.');
})();

El uso correcto es diferencial: mide, ejecuta el ciclo diez veces, vuelve a medir. Un crecimiento de diez en algún evento concreto es un diagnóstico completo en treinta segundos, sin abrir el panel de memoria.

Para vigilancia continua durante el desarrollo, este interceptor cuenta registros y bajas y avisa del desequilibrio:

// Contabilidad de altas y bajas de escuchas, para desarrollo
(() => {
  const cuentas = new Map();
  const clave = (objetivo, tipo) =>
    (objetivo === window ? 'window' : objetivo?.nodeName || String(objetivo)) + ' :: ' + tipo;

  const alta = EventTarget.prototype.addEventListener;
  const baja = EventTarget.prototype.removeEventListener;

  EventTarget.prototype.addEventListener = function (tipo, fn, opts) {
    const k = clave(this, tipo);
    const c = cuentas.get(k) || { altas: 0, bajas: 0 };
    c.altas++; cuentas.set(k, c);
    return alta.call(this, tipo, fn, opts);
  };
  EventTarget.prototype.removeEventListener = function (tipo, fn, opts) {
    const k = clave(this, tipo);
    const c = cuentas.get(k) || { altas: 0, bajas: 0 };
    c.bajas++; cuentas.set(k, c);
    return baja.call(this, tipo, fn, opts);
  };

  window.balanceEscuchas = (soloDesequilibrados = true) => {
    const filas = [...cuentas].map(([k, c]) => ({
      objetivoYEvento: k, altas: c.altas, bajas: c.bajas, vivos: c.altas - c.bajas
    })).filter(f => !soloDesequilibrados || f.vivos > 0)
      .sort((a, b) => b.vivos - a.vivos);
    console.table(filas);
    return filas;
  };

  window.restaurarEscuchas = () => {
    EventTarget.prototype.addEventListener = alta;
    EventTarget.prototype.removeEventListener = baja;
    console.log('Interceptores retirados.');
  };

  console.log('Contabilizando. Ejecuta el ciclo y luego balanceEscuchas().');
})();

Este instrumento tiene un sesgo que hay que conocer: los escuchas dados de baja mediante una señal de aborto no pasan por el método de baja, así que aparecerán como desequilibrados aunque estén bien. No es un fallo grave —significa que el instrumento avisa de más, no de menos— pero conviene saberlo para no perseguir falsos positivos en código bien escrito.

Los cinco tipos de suscripción

Mecanismo Alta Baja Qué retiene si no se da de baja
Escucha de evento addEventListener removeEventListener o abortar la señal El manejador y todo su ámbito
Observador observe unobserve o disconnect Los nodos observados y el callback
Temporizador setInterval, setTimeout clearInterval, clearTimeout La callback y su ámbito, indefinidamente en el caso del intervalo
Suscripción a un almacén Según la librería La función que devuelve el alta El suscriptor y su ámbito
Conexión persistente new WebSocket, new EventSource close El manejador de mensajes y su ámbito

Los tres primeros son los más conocidos. Los dos últimos son los que más veces se olvidan.

Las suscripciones a un almacén fugan de forma especialmente silenciosa porque su alta devuelve una función de baja que es fácil descartar: almacen.suscribir(fn) sin guardar el retorno es una fuga garantizada, y el código compila, funciona y no da ningún síntoma hasta que la sesión es larga.

Las conexiones persistentes retienen además recursos de red, y una aplicación que abre una conexión por vista sin cerrarlas acaba agotando el límite de conexiones simultáneas además de la memoria.

El error de la función distinta

Este merece sección propia porque es el fallo que hace que limpiezas escritas correctamente en apariencia no limpien nada.

Para dar de baja un escucha hay que pasar exactamente la misma referencia de función con la que se registró. Y hay tres formas de romper esa igualdad sin darse cuenta:

// Los tres errores que hacen que removeEventListener no quite nada
const el = document.createElement('div');

// Error 1: funcion flecha creada al vuelo en las dos llamadas.
// Son dos funciones distintas: la baja no encuentra nada que quitar.
el.addEventListener('click', () => manejar());
el.removeEventListener('click', () => manejar());   // no hace nada

// Error 2: enlace al vuelo. bind devuelve una funcion nueva cada vez.
class Componente {
  montar(el) { el.addEventListener('click', this.alPulsar.bind(this)); }
  desmontar(el) { el.removeEventListener('click', this.alPulsar.bind(this)); } // no hace nada
  alPulsar() {}
}

// Error 3: opciones distintas. La fase de captura forma parte de la identidad.
el.addEventListener('scroll', manejar, true);
el.removeEventListener('scroll', manejar);          // no hace nada: falta true

function manejar() {}

// Las tres formas correctas
const manejador = () => manejar();
el.addEventListener('click', manejador);
el.removeEventListener('click', manejador);          // si funciona

class ComponenteBien {
  alPulsar = () => {};                                // campo de clase: una sola funcion
  montar(el) { el.addEventListener('click', this.alPulsar); }
  desmontar(el) { el.removeEventListener('click', this.alPulsar); }
}

const control = new AbortController();
el.addEventListener('scroll', manejar, { capture: true, signal: control.signal });
control.abort();                                     // funciona sin repetir opciones

La última forma es la que conviene adoptar por defecto, porque elimina la posibilidad del error: no hay que guardar la referencia, no hay que repetir las opciones, y una sola llamada da de baja todos los registros asociados a la señal.

Lo peor de estos tres errores es que son silenciosos. No hay excepción, no hay aviso, no hay valor de retorno que comprobar. El código parece correcto en la revisión y en las pruebas, y la fuga solo se manifiesta en sesiones largas.

La asimetría entre registrar y dar de baja es un defecto de diseño de la plataforma, y la señal de aborto es la corrección

Merece la pena reconocer explícitamente que esta clase de fuga no es principalmente un fallo de disciplina de quien programa, sino la consecuencia previsible de una asimetría en el diseño de la API que estuvo vigente durante veinticinco años. Registrar un escucha es una operación de una línea, sin ceremonia, que se puede hacer con una función anónima escrita en el sitio. Darlo de baja exige haber previsto la baja en el momento del alta: haber guardado la función en una variable con un ámbito que sobreviva hasta la limpieza, haber recordado las opciones exactas, y tener un sitio donde poner la llamada de baja. Es decir, la operación fácil es la que fuga y la operación difícil es la que limpia, y encima la difícil falla en silencio cuando se hace mal. Cualquier API con esa forma produce fugas a escala, independientemente de lo cuidadoso que sea el equipo, porque el coste de hacerlo bien se paga en el momento de escribir y el beneficio se recibe meses después en un problema que nadie atribuirá a esa línea. La señal de aborto corrige exactamente esa asimetría y por eso su adopción cambia la estadística de un proyecto entero: mueve el coste de la limpieza del sitio del registro al sitio de la destrucción, que es donde la persona ya está pensando en limpiar, y lo agrupa todo en una sola llamada que no puede escribirse mal. Un componente con un controlador de aborto no tiene forma de olvidarse de un escucha, porque el escucha se registró con la señal y la señal se aborta pase lo que pase. La generalización que conviene llevarse va más allá de los eventos: cuando una categoría de bug se repite en todos los proyectos y en todos los equipos, la causa raíz casi nunca es la disciplina, y buscar la solución en más revisiones de código o más atención es tirar el esfuerzo. La solución está en cambiar la forma del código para que el error deje de ser expresable: una función de creación que devuelve su propia limpieza, un ámbito que se cierra solo, una señal que cancela todo. Esa es la diferencia entre un equipo que arregla fugas y uno que dejó de tenerlas.