wandres.dev
MEMORIA · Fugas y presión

El heap y la presión de memoria: qué es realmente una fuga

Cómo está organizado el heap de JavaScript, por qué el recolector no te salva de las fugas, y qué le pasa a una pestaña cuando la memoria se acaba.

⏱ 17 min

En un lenguaje con recolección de basura, una fuga de memoria no es memoria perdida: es memoria alcanzable que ya no se necesita. El recolector hace su trabajo perfectamente y libera todo lo inalcanzable; el problema es que tu código sigue apuntando a cosas que ya no usa, y desde el punto de vista del recolector eso es indistinguible de un dato en uso. Entender esa diferencia es lo que convierte la caza de fugas de un ejercicio de adivinación en un procedimiento.

🎯 Al terminar esta lección sabrás
  • Describir la organización del heap de JavaScript y las dos clases de recolección.
  • Definir una fuga en términos de alcanzabilidad y no de liberación.
  • Explicar cómo la presión de memoria degrada el rendimiento antes de agotarla.
  • Distinguir “consume mucho” de “tiene una fuga” con una medición.

Cómo está organizado el heap

El motor no trata toda la memoria igual, porque los objetos no se comportan igual. La observación empírica que sostiene todo el diseño es la hipótesis generacional: la gran mayoría de los objetos mueren jóvenes. El resultado de una operación intermedia, un objeto de evento, una cadena temporal.

De ahí salen dos zonas y dos recolectores.

La generación joven es pequeña —del orden de unos pocos megabytes— y se recolecta con frecuencia mediante copia: los objetos que siguen vivos se copian a otra mitad del espacio y el resto se descarta en bloque, sin recorrerlo. El coste de esa recolección es proporcional a lo que sobrevive, no a lo que se asignó, así que asignar mucha basura de vida corta es sorprendentemente barato. Las pausas son de fracciones de milisegundo.

La generación vieja recibe los objetos que sobreviven a un par de recolecciones jóvenes. Se recolecta con marcado y compactación, y es la cara: hay que recorrer el grafo entero de objetos alcanzables. Los motores modernos hacen la mayor parte de ese marcado en hilos de fondo y de forma incremental, precisamente para no bloquear el hilo principal, pero las fases que exigen coherencia siguen parando la ejecución. Cuanto más grande es el conjunto vivo, más largas y más frecuentes son esas pausas.

Este reparto explica dos comportamientos que confunden. Que crear un millón de objetos temporales en un bucle no aparezca en el perfil como problema de memoria, porque mueren jóvenes y salen gratis. Y que retener un solo array de cien mil objetos sí lo empeore todo, porque ese array vive en la generación vieja y hay que recorrerlo en cada marcado.

Qué es una fuga exactamente

El recolector libera lo que no es alcanzable desde las raíces: el objeto global, la pila de llamadas, los nodos del DOM conectados al documento, y unas cuantas más. Todo lo demás se va.

Por tanto el recolector nunca se equivoca liberando de menos por su cuenta. Si algo no se libera es porque hay un camino de referencias desde una raíz hasta ese objeto. Una fuga es siempre uno de estos:

  • Una referencia global que crece y de la que nunca se quita nada: un array, un Map, una caché sin política de expulsión.
  • Un listener registrado y nunca retirado, que mantiene viva su función, que mantiene vivo su ámbito, que mantiene vivo lo que ese ámbito capturó.
  • Un temporizador o un observador que nadie canceló.
  • Un nodo del DOM que quitaste del documento pero que sigue referenciado desde JavaScript.
  • Un cierre que captura más de lo que parece.

Los cinco casos comparten forma: hay un camino. Encontrar ese camino es literalmente el trabajo, y las herramientas del navegador están construidas alrededor de esa idea, con el nombre de retainer path.

La consecuencia práctica es que la pregunta correcta al depurar nunca es “¿por qué no se libera esto?” sino “¿quién lo está referenciando?”. La primera no tiene respuesta útil; la segunda la responde el panel de memoria en dos clics.

Qué pasa cuando la memoria escasea

Antes de que una pestaña se quede sin memoria, hay una degradación gradual que se nota mucho y que casi nadie atribuye a la memoria.

Primero, más recolecciones. Cuando el conjunto vivo crece, el motor ajusta sus umbrales y recolecta con más frecuencia. Cada recolección mayor tiene fases que paran el hilo principal. En el perfil aparecen como barras de recolección repartidas, y su efecto sobre el INP es directo: la pausa que cae justo después de un clic se suma a la latencia de esa interacción.

Segundo, peor localidad. Un heap grande y fragmentado tiene peor comportamiento de caché de CPU. El mismo código va más lento sin que nada haya cambiado en él.

Tercero, el límite. performance.memory.jsHeapSizeLimit, en los navegadores que lo exponen, te dice cuánto puede crecer el heap de JavaScript. En un escritorio de 64 bits ronda los dos gigabytes; en un móvil de gama baja son unos pocos cientos de megabytes. Superarlo no lanza una excepción que puedas capturar: la pestaña muere.

Cuarto, y mucho antes del límite, el descarte. Cuando el sistema operativo tiene presión de memoria, el navegador descarta pestañas de fondo para liberar. Al volver a ellas, se recargan desde cero. Para el usuario esto es peor que una caída: ha perdido el formulario a medio rellenar y no entiende por qué. En móviles de gama baja con varias pestañas abiertas, este es el fallo de memoria más frecuente con diferencia, y ocurre con consumos que en el escritorio parecerían modestos.

Hay una API específica para detectar que le pasó a tu usuario:

// Chromium expone si esta carga viene de una pestana descartada.
if (document.wasDiscarded) {
  // El usuario perdio el estado. Restauralo desde sessionStorage
  // y, sobre todo, cuentalo como metrica: es una senal de presion
  // de memoria que ninguna otra te va a dar.
  restaurarBorrador();
  enviarMetrica({ tipo: 'pestana-descartada' });
}

Consumo alto frente a fuga

Son dos problemas distintos con soluciones distintas y se confunden constantemente.

Consumo alto es una aplicación que estabiliza en trescientos megabytes. Puede ser un problema —en gama baja lo es— pero es estable: hagas lo que hagas, vuelve al mismo sitio. Se ataca reduciendo lo que se guarda: menos datos en memoria, menos nodos, imágenes más pequeñas.

Fuga es una aplicación que crece de forma monótona con el uso. Cada navegación entre secciones deja un poco más. Se ataca encontrando el camino de referencias.

Distinguirlas es una medición de diez minutos y no necesita el panel de memoria:

// Deja esto en la consola durante una sesion de uso real.
const muestras = [];
setInterval(() => {
  if (!performance.memory) return;
  muestras.push({
    t: Math.round(performance.now() / 1000),
    mb: +(performance.memory.usedJSHeapSize / 1048576).toFixed(1),
  });
  console.clear();
  console.table(muestras.slice(-20));
}, 5000);

El procedimiento: haz el mismo ciclo de uso —entrar en una sección, salir, volver— diez veces seguidas y mira la serie. Si sube y baja alrededor de un valor, es consumo. Si cada ciclo deja el suelo un poco más alto que el anterior, es fuga. El indicador no es el máximo: es el mínimo después de cada recolección. Un máximo alto es normal; un mínimo que crece no lo es nunca.

Dos avisos sobre performance.memory. No es estándar y solo existe en Chromium. Y sus valores están cuantizados a saltos de unos cien kilobytes y con un tope, deliberadamente, porque una medida precisa de la memoria de un proceso es un canal lateral que permite deducir cosas de otros orígenes. Para medir en serio hace falta la API estándar, con sus condiciones, que es el tema de medir memoria en producción.

La recolección de basura no resuelve la gestión de memoria: mueve el problema de la corrección al diseño

Existe la idea, muy extendida, de que un lenguaje con recolector te libera de pensar en memoria, y que las fugas en JavaScript son un accidente raro. Es exactamente al revés, y merece la pena ver por qué. Lo que el recolector elimina es una clase de errores de corrección: no vas a leer memoria liberada, no vas a liberar dos veces, no vas a tener punteros colgando. Ese es un logro enorme y explica que la web sea programable por millones de personas. Lo que el recolector no puede hacer, ni ninguna implementación futura podrá, es decidir si un objeto que tú todavía referencias te sigue haciendo falta. Eso es una pregunta sobre la intención del programa, no sobre su estructura, y no hay análisis estático ni dinámico que la responda. De ahí sale la asimetría que define el trabajo real: en un lenguaje sin recolector, las fugas son visibles porque cada asignación tiene una liberación que puedes buscar y que falta; en uno con recolector, las fugas son invisibles, porque no hay ninguna línea de código ausente. La fuga no está en lo que escribiste, está en lo que no borraste. Un array.push en un manejador, un listener en window, un Map que crece: todas son líneas perfectamente correctas, que un revisor aprueba sin pestañear, que pasan todos los tests, y que degradan la aplicación al cabo de veinte minutos de uso. La conclusión práctica cambia la forma de programar más de lo que parece. Como no puedes detectar fugas leyendo código, tienes que diseñar para que no puedan ocurrir: cada cosa que registra tiene que devolver cómo se desregistra, cada caché nace con su política de expulsión el mismo día en que se escribe, cada referencia a un nodo del DOM se plantea si debe ser débil, y cada componente tiene una función de limpieza aunque hoy esté vacía. Y como tampoco puedes verificar el diseño leyendo, tienes que medir: la serie temporal del mínimo del heap durante una sesión real es la única prueba que existe de que no tienes una fuga, y cuesta diez minutos.

⚔️ Clasifica tu problema
  1. Ejecuta el muestreo del heap durante diez ciclos del flujo más usado de tu aplicación. Decide si es consumo o fuga con el criterio del mínimo.
  2. Consulta performance.memory.jsHeapSizeLimit en tu escritorio y en un móvil real. Anota la diferencia.
  3. Provoca una recolección mayor forzada desde el panel de memoria y observa el efecto en el perfil de rendimiento.
  4. Añade la comprobación de document.wasDiscarded y registra cuántas veces les pasa a tus usuarios reales.
  5. Escribe, para tu aplicación, la lista de las cinco fuentes de fuga y quién es el responsable de cada una en tu código.