Fugas de suscripción: el efecto que nunca se limpia
La última familia de bugs del nivel y la más silenciosa: el efecto que se da de alta y jamás se da de baja. No falla nada al principio, y por eso sobrevive a todas las revisiones de código y a todos los ciclos de pruebas manuales. Esta lección disecciona la anatomía de la fuga a partir de la asimetría que la origina, recorre los tres escondites donde se acumulan casi todas —oyentes de eventos, temporizadores y suscripciones a fuentes externas al framework—, y explica el síntoma doble que la delata: memoria que crece de forma monótona con la navegación y trabajo que se multiplica porque cada instancia zombi sigue reaccionando. Cierra con la parte que casi nadie hace: medirla con instantáneas de memoria comparadas entre navegaciones idénticas, y convertirla en un test automático que cuente suscriptores antes y después de desmontar.
Las cuatro lecciones anteriores perseguían fallos que se ven: algo no cambia, algo cambia de más, algo se resiste al análisis. Esta cierra el nivel con la familia que no se ve en absoluto, y que por eso mismo llega a producción con una fiabilidad casi cómica. Una fuga de suscripción no rompe nada el primer día. La pantalla es correcta, los datos son correctos, la revisión de código la aprueba sin comentarios y las pruebas manuales pasan, porque durante la primera visita a la pantalla el sistema se comporta exactamente como debe. El problema es que cada visita posterior deja atrás un fantasma: un oyente registrado que nadie retiró, un temporizador que sigue latiendo, una suscripción al store que sobrevive al componente que la creó. La aplicación no falla, se degrada, y la degradación es monótona y acumulativa: a los diez minutos de uso real hay cuarenta copias del mismo efecto reaccionando a cada escritura, cada una manteniendo viva por una referencia toda la porción de estado que capturó. El origen de todo esto es una asimetría de una sola línea, y es la línea que más se olvida en todo el desarrollo reactivo.
- Explicar la fuga como una asimetría entre el alta y la baja de una suscripción, y no como un error de memoria.
- Reconocer los tres escondites habituales: oyentes de eventos, temporizadores y fuentes externas al framework.
- Interpretar el síntoma doble en memoria y en rendimiento, y distinguir la fuga de una simple lentitud.
- Medir la fuga con instantáneas comparadas y blindarla con un test que cuente suscriptores tras desmontar.
El zombi que sigue reaccionando
El componente murió, su efecto no. Sigue despertando en cada escritura y sigue reteniendo por referencia todo lo que capturó.
Memoria monótona
El síntoma medible: navegar a la pantalla y salir diez veces deja el montón diez veces más grande, y ninguna recolección lo baja.
Trabajo multiplicado
Una única escritura dispara la misma lógica una vez por instancia zombi. La lentitud crece con el tiempo de sesión.
Anatomía: la asimetría de una sola línea
Toda suscripción es un contrato con dos cláusulas: te doy una función para que la llames cuando algo cambie, y te prometo decirte cuándo dejar de llamarla. La fuga aparece cuando se firma la primera y se olvida la segunda, y el motivo por el que se olvida tan a menudo es puramente sintáctico: dar de alta es una expresión visible, con nombre y argumentos, mientras que dar de baja es un valor de retorno que puede sencillamente no escribirse sin que nada proteste. Ningún tipo lo exige, ningún compilador lo avisa, ninguna prueba lo detecta.
// Fuga: alta sin baja. Cada montaje deja un oyente vivo para siempre.
useEffect(() => {
window.addEventListener('resize', alRedimensionar)
}, [])
// Simetria restaurada: la funcion devuelta es la baja del contrato.
useEffect(() => {
window.addEventListener('resize', alRedimensionar)
return () => window.removeEventListener('resize', alRedimensionar)
}, [])
Hay una segunda asimetría, más sutil, que multiplica el daño de la primera: la suscripción no es lo único que sobrevive. Lo que se retiene es el cierre completo que la rodea, con todas las variables que capturó, y entre ellas suele estar una porción del estado, una referencia a un elemento del árbol y a veces la respuesta entera de una petición. Por eso una fuga que en el código ocupa una línea puede costar megabytes por instancia: el oyente es el hilo del que cuelga todo lo demás, y mientras el objeto observado siga apuntándolo, nada de ese subgrafo puede recolectarse.
Hay un matiz que causa fugas incluso cuando el retorno existe: la baja debe cancelar exactamente el alta que hizo esa misma ejecución, no una equivalente. Si la función que registras se recrea entre ejecuciones y das de baja una referencia distinta, el navegador retirará un oyente que no existe y dejará vivo el que sí. La comparación aquí vuelve a ser por identidad, igual que en toda la reactividad, y por eso este bug pertenece a este nivel y no al de rendimiento.
Los tres escondites
El primero son los oyentes de eventos sobre objetos que sobreviven al componente: window, document, el objeto de historia del router, un elemento de audio o de vídeo, un WebSocket, un ResizeObserver o un IntersectionObserver. La clave común es que el objeto observado tiene un ciclo de vida más largo que quien lo observa, así que la referencia al callback mantiene vivo todo el cierre que lo rodea, incluido el estado que capturó.
El segundo son los temporizadores. Un setInterval sin clearInterval es la fuga más cara de todas porque no solo retiene memoria: consume tiempo de procesador de forma indefinida y, si escribe estado, provoca escrituras sobre componentes que ya no existen. Un setTimeout largo es su versión benigna, pero produce un fallo característico igualmente molesto cuando se dispara sobre un componente desmontado.
El tercero, y el más específico de este track, son las suscripciones a fuentes que el framework no gobierna: un store externo suscrito a mano, un efecto de grano fino creado fuera de un ámbito con dueño, una petición en vuelo sin AbortController. Aquí el framework no puede ayudarte porque no sabe que la suscripción existe, y es donde se concentran las fugas de las aplicaciones maduras.
Dentro de este tercer escondite hay un caso que merece nombre propio porque desconcierta a quien viene de sistemas basados en componentes: el efecto de grano fino creado sin dueño. En Solid, en Vue o en cualquier implementación de signals, un efecto creado dentro de un ámbito reactivo se cancela cuando ese ámbito se destruye, y por eso el modelo parece no fugar nunca. La ilusión se rompe en cuanto creas uno fuera de ese ámbito —dentro de una promesa resuelta más tarde, en un manejador de eventos, en un módulo cargado bajo demanda—, porque entonces no hay nadie que lo posea y vive hasta que se recargue la página. El grano fino no elimina las fugas: las concentra en los lugares donde la propiedad se pierde.
// Los tres escondites, con su baja correspondiente.
useEffect(() => {
const id = setInterval(refrescar, 1000)
const cancelar = store.subscribe(alCambiar)
const control = new AbortController()
fetch('/api/datos', { signal: control.signal }).then(procesar).catch(ignorarAborto)
return () => { clearInterval(id); cancelar(); control.abort() }
}, [])
El modo estricto de desarrollo monta cada componente, lo desmonta y lo vuelve a montar de inmediato, y mucha gente lo desactiva porque ve las cosas duplicarse. Es exactamente al revés de lo que conviene hacer. Ese doble montaje es un detector de fugas incorporado y gratuito: un efecto correctamente simétrico es indistinguible con y sin él, porque la baja deshace el alta y el segundo montaje parte de cero. Un efecto que fuga, en cambio, se delata al instante con dos peticiones, dos oyentes o un contador que avanza el doble de rápido. Si el modo estricto rompe tu componente, no ha introducido un bug: ha revelado uno que ya tenías y que en producción tardarías semanas en ver. Desactivarlo es apagar la alarma en lugar de apagar el fuego, y la factura llega igual, solo que más tarde y con menos información.
El síntoma doble: memoria y trabajo
Una fuga se manifiesta en dos ejes a la vez y reconocer el par es lo que la distingue de una lentitud cualquiera. En memoria, el consumo crece de forma monótona con la navegación y no baja: entrar y salir diez veces de la misma pantalla deja diez copias del estado retenidas por referencias vivas, así que ninguna recolección puede liberarlas. Esto no es un fallo del recolector; es su comportamiento correcto ante objetos alcanzables, y por eso la palabra fuga es exacta aquí pese a que el lenguaje tenga gestión automática.
En rendimiento, cada escritura de estado despierta a todos los suscriptores vivos, incluidos los zombis, de modo que el coste de una interacción crece linealmente con el tiempo que lleva abierta la sesión. Esa es la firma que ninguna otra causa produce: una aplicación que arranca fluida y se vuelve pesada con el uso, mientras que recargar la página la devuelve al estado original. Si recargar arregla la lentitud, tienes una fuga; si la lentitud está ahí desde el primer segundo, tienes un problema de granularidad, que es otro nivel.
Existe un tercer síntoma, menos conocido y muy útil porque aparece antes que los otros dos: los efectos duplicados con consecuencias visibles. Una petición que se envía tres veces en lugar de una, una notificación que aparece por triplicado, un analítico que cuenta cada evento tantas veces como veces se visitó la pantalla en la sesión. Estos casos son en realidad la misma fuga manifestándose por su lado ruidoso, y tienen la virtud de delatarse en la pestaña de red mucho antes de que la memoria alcance un nivel preocupante. Cuando veas una petición idéntica repetida un número de veces sospechosamente igual al número de visitas a esa pantalla, no busques un bucle: busca una baja que falta.
flowchart TD A[alta de la suscripcion en el montaje] --> V[componente vivo y reaccionando] V --> D[desmontaje] D -->|con baja| L[liberado: memoria estable y cero trabajo] D -->|sin baja| Z[zombi: sigue suscrito y retiene su cierre] Z --> M[memoria monotona creciente] Z --> W[trabajo multiplicado por instancia] M --> S[recargar la pagina lo cura: firma de fuga] W --> S style Z fill:#f38ba8,color:#11111b style L fill:#a6e3a1,color:#11111b style S fill:#f9e2af,color:#11111b
Medirla y convertirla en test
La defensa estructural, la que hace innecesaria la disciplina individual, consiste en atar cada suscripción a un objeto que la posea y cancelarlas todas en bloque cuando ese propietario muere. Es el mismo patrón que gobierna cualquier gestión seria de recursos y se implementa en muy pocas líneas.
// Un dueno para todas las bajas: cancelar por pertenencia, no por disciplina.
function crearAmbito() {
const bajas: Array<() => void> = []
return {
posee<T>(alta: () => () => void): void { bajas.push(alta()) },
destruir() { for (const baja of bajas.splice(0)) baja() },
}
}
const ambito = crearAmbito()
ambito.posee(() => store.subscribe(alCambiar))
ambito.posee(() => { const id = setInterval(refrescar, 1000); return () => clearInterval(id) })
// Una sola llamada cancela todo lo adquirido, sin que nadie tenga que recordarlo.
La medición manual es un procedimiento de tres pasos que da resultados concluyentes en cinco minutos. Abre el panel de memoria, toma una instantánea del montón, navega a la pantalla sospechosa y sal de ella diez veces seguidas, fuerza la recolección y toma una segunda instantánea. Compara ambas filtrando por los constructores de tu dominio: si hay diez instancias de algo que debería tener cero, la fuga está localizada, y el árbol de retención te dirá qué referencia las mantiene vivas. Ese árbol suele apuntar directamente a la lista de oyentes de un objeto global, que es la confesión completa.
// Test de fuga: la unica forma de que no vuelva a colarse en una revision.
test('el componente se da de baja al desmontarse', () => {
const antes = store.numeroDeSuscriptores()
const vista = render(<Panel />)
expect(store.numeroDeSuscriptores()).toBe(antes + 1)
vista.unmount()
expect(store.numeroDeSuscriptores()).toBe(antes) // falla si el efecto fuga
})
Esa aserción, trivial de escribir, es la única defensa real contra la reaparición del bug, porque es lo único que convierte una omisión invisible en un fallo ruidoso. Exponer un contador de suscriptores en tu store para uso exclusivo de pruebas cuesta tres líneas y paga por sí solo la primera vez que impide una fuga en una revisión de código.
El error metodológico que invalida más mediciones de memoria es comparar dos instantáneas que no representan el mismo trabajo. Si entre la primera y la segunda navegaste por rutas distintas, cargaste datos nuevos o dejaste abierta la extensión de depuración, el crecimiento que observes puede ser cache legítima, código cargado bajo demanda o referencias que la propia herramienta retiene para su viaje en el tiempo. La comparación válida exige un ciclo cerrado y repetido: parte de la pantalla A, ve a la B, vuelve a la A, y repite ese trayecto exacto varias veces antes de la segunda instantánea. El estado final es idéntico al inicial en todo salvo en lo que fugó, y por eso cualquier diferencia monótona que sobreviva a una recolección forzada es concluyente. Una sola navegación nunca demuestra nada; diez idénticas lo demuestran todo.
Vale la pena cerrar el nivel entendiendo por qué esta familia de bugs es estructural y no un descuido que la industria acabará puliendo. Todo el atractivo de la reactividad moderna consiste en hacer implícito el vínculo entre un dato y quien lo observa: no declaras suscripciones, lees un valor y el sistema deduce la dependencia. Esa comodidad es enorme y es la razón por la que los signals ganaron. Pero una dependencia que se crea implícitamente al leer solo puede destruirse explícitamente al terminar, porque el sistema sabe cuándo empezaste a mirar y no tiene forma de saber cuándo dejaste de importarte, salvo que se lo digas. Ahí está la asimetría irreductible: el alta es automática, la baja es un acto de voluntad. Toda la ergonomía del modelo se paga en esa única línea. Fíjate en que los frameworks que menos fugan no resuelven el problema, lo trasladan: lo que hacen es atar cada suscripción a un ámbito con dueño —el ciclo de vida de un componente, un contexto reactivo, un scope que se destruye en bloque— de modo que la baja ocurra por pertenencia y no por disciplina individual. Esa es la solución correcta y es un patrón, no una herramienta: cuando el propietario muere, todo lo que le pertenece se cancela con él. Es la misma idea que gobierna la gestión de recursos en cualquier sistema serio, desde los destructores hasta la propiedad en Rust, y aparece aquí por la misma razón de siempre, porque una suscripción es un recurso adquirido. El nivel entero se cierra así con la simetría que lo recorría desde la primera lección: una arista que falta te deja sin actualizar, una arista de sobra te actualiza de más, y una arista que se niega a desaparecer te hunde despacio. Depurar reactividad es, en las tres direcciones, cuidar de que el grafo real tenga exactamente las aristas que creías, y ni una más, ni una menos, ni una de más tiempo del debido.
- Reactiva el modo estricto si lo tenías desactivado y anota qué efectos se delatan al duplicarse; cada uno es una fuga confirmada.
- Toma dos instantáneas de memoria separadas por diez navegaciones idénticas y localiza los constructores de tu dominio que crecen de forma monótona.
- Sigue el árbol de retención de una de esas instancias hasta la referencia que la mantiene viva y nombra el escondite exacto al que pertenece.
- Audita cada efecto de tu aplicación comprobando que la baja cancela precisamente el alta de esa ejecución, y no una referencia equivalente.
- Expón un contador de suscriptores en tu store y escribe el test de desmontaje para los tres componentes que más se montan y desmontan.
- Rediseña una suscripción manual para que quede atada a un ámbito con dueño, y comprueba que ya no puede fugar aunque nadie recuerde limpiarla.