Garbage Collection en V8: por qué existen Memory Leaks aunque JavaScript tenga recolector de basura

Publicado: 06/03/2026 Por: Juan Felipe Orozco Cortés
📚 Contenido de la guía

Más allá del mito: Lo que pasa en el Heap cuando crees que JavaScript se limpia solo.

Promesa de aprendizaje:

Aprenderás cómo V8 decide qué objetos destruir y, más importante aún, aprenderás a identificar esas "referencias vivas" que mantienen basura en memoria, bloqueando la capacidad de tu app en navegador o Node.js.

Las 5 preguntas clave que resolveremos:

  • ¿Por qué el GC no elimina objetos aunque tú ya no los necesites?
  • ¿Cuál es la diferencia real entre la generación joven y la vieja en V8?
  • ¿Cuándo un objeto "vivo" se convierte técnicamente en un memory leak?
  • ¿Cómo comparar Heap Snapshots para encontrar al culpable de una fuga?
  • ¿Qué riesgos reales existen al tomar snapshots en un entorno de producción en Node.js?
Ilustración del heap de V8 con recolección de basura y fuga de memoria retenida.
El GC de V8 libera lo inalcanzable, pero una referencia viva puede seguir reteniendo memoria que ya no aporta valor a la aplicación.

1) La Paradoja: ¿Si JavaScript tiene GC, por qué existen Memory Leaks?

Existe un mito fundacional en el desarrollo web: "Como JavaScript tiene un recolector de basura (Garbage Collector), no necesito preocuparme por la gestión de memoria". Si esto fuera cierto, las herramientas de perfilado de memoria en Chrome o Node.js no existirían, y las aplicaciones de larga duración no terminarían colapsando por agotamiento de RAM.

La realidad es mucho más cínica: El Garbage Collector (GC) no es un sistema inteligente que se pregunta "¿esto le sirve al usuario?". El GC es un algoritmo ciego que solo se pregunta: "¿este objeto sigue siendo alcanzable?".

El problema surge cuando tu código mantiene referencias vivas hacia objetos que, para ti, ya son basura. Para el motor V8, si existe un camino (una cadena de referencias) que conecta una "raíz" (root) con un objeto, ese objeto está vivo. Punto. No importa si ya no lo usas o si tu interfaz ya lo destruyó; si la referencia existe, el objeto ocupa memoria.

TL;DR:

  • El GC solo limpia lo que es inalcanzable.
  • Una "fuga" suele ser una referencia olvidada que mantiene vivo un objeto "muerto".
  • La clave no es liberar memoria, sino romper las cadenas de referencia cuando ya no son necesarias.
  • JavaScript no te salva de la mala gestión de referencias; solo automatiza la limpieza de lo que efectivamente soltaste.
Cadena de referencias desde una raíz hacia un objeto aislado pero aún retenido.
Aunque un objeto parezca separado o inútil, mientras siga conectado a una raíz por una cadena de referencias, el garbage collector lo considera alcanzable y no puede liberarlo.

2) Qué hace realmente un Garbage Collector

Llamar a esto "recolector de basura" es la simplificación más peligrosa de la programación. En realidad, el Garbage Collector (GC) es un gestor de ciclo de vida. Su trabajo no es "limpiar", sino preservar la integridad del grafo de memoria.

Conceptos clave antes de empezar

  • Heap: Imagina el Heap como el gran taller de trabajo de tu aplicación. Es el bloque de memoria donde V8 aloja todos tus objetos, arrays y funciones.
  • Roots (Raíces): Son los puntos de anclaje. Cualquier objeto al que se pueda llegar navegando desde una raíz se considera "vivo". Ejemplos: el objeto global (window), variables en el stack actual o registros de CPU.
  • Reachability (Alcanzabilidad): Si puedes trazar una línea ininterrumpida desde una raíz hasta tu objeto, ese objeto es alcanzable. Si la línea se rompe, el objeto es "basura", sin importar si contiene información valiosa o no.

Las fases del proceso

El GC no hace todo esto en un solo paso. Generalmente, el proceso se divide en tres fases que trabajan juntas:

  1. Mark (Marcar): El recolector viaja desde las raíces y "marca" todos los objetos que encuentra. Si llegaste a él, estás vivo.
  2. Sweep (Barrer): El recolector escanea el Heap. Cualquier objeto que no haya sido "marcado" es declarado muerto y su memoria se libera.
  3. Compact (Compactar): Opcional pero crítico. Mueve los objetos vivos para que queden contiguos, eliminando los huecos (fragmentación) y dejando espacio libre en un solo bloque grande.
Secuencia visual de Mark, Sweep y Compact en el garbage collector.
El GC no “borra todo de una vez”: primero marca los objetos alcanzables, luego elimina los no usados y, por último, puede reorganizar la memoria superviviente para reducir fragmentación.

3) Cómo organiza V8 su Heap: El Modelo Generacional

El equipo detrás del motor V8 (y de casi todos los motores modernos) basa su arquitectura en un principio estadístico comprobado empíricamente, conocido como la Hipótesis Generacional: "La gran mayoría de los objetos mueren muy jóvenes".

Piensa en las variables temporales dentro de un bucle for, o el objeto de respuesta de una petición a una API que usas para renderizar y luego descartas. Viven milisegundos. Tratar a esos objetos efímeros con la misma pesadez que a la caché global de tu aplicación sería un desperdicio masivo de CPU. Por eso, V8 divide el Heap en dos compartimentos principales:

  • Generación Joven (Young Generation): Es la sala de partos. Aquí nacen casi todos los objetos. Es una región de memoria pequeña, diseñada para ser escaneada y limpiada a una velocidad extrema (usando un algoritmo llamado Scavenger). Los objetos aquí mueren rápido y la limpieza no paraliza tu aplicación.
  • Generación Vieja (Old Generation): Es el asilo. Si un objeto sobrevive a dos o más ciclos de limpieza en la Generación Joven, V8 deduce que es importante y lo promueve a la Generación Vieja. Esta zona es mucho más grande y su limpieza es pesada, costosa y requiere el ciclo completo de Mark, Sweep y Compact que vimos antes.
Diagrama del heap generacional de V8 con memoria joven, promoción y memoria vieja.
V8 divide el heap en una generación joven, donde nacen y mueren la mayoría de los objetos, y una generación vieja, donde terminan los que sobreviven lo suficiente como para ser promovidos.

¿Ves el patrón? El verdadero peligro no es crear muchas variables temporales; V8 las limpiará sin sudar. El peligro mortal (los Memory Leaks) ocurre cuando promueves accidentalmente basura a la Generación Vieja, obligando al recolector principal a hacer trabajos pesados y paralizar el hilo de ejecución de tu aplicación.

5) La Tríada del Recolector Mayor: Mark, Sweep y Compact

Cuando un objeto llega a la Generación Vieja, V8 saca la artillería pesada: el Major GC (Recolector Mayor). Muchos desarrolladores imaginan el Garbage Collection como un rayo láser que destruye el código malo al instante. En la realidad, es un proceso industrial de tres pasos que no siempre ocurren al mismo tiempo.

Por qué debemos separar estos tres conceptos:

  • Fase 1: Mark (Marcar). Es el trabajo de detective. El recolector pausa temporalmente tu código (o usa hilos concurrentes gracias al motor Orinoco) y recorre el árbol de referencias desde las Roots. Todo lo que toca recibe una "marca" (es pintado como vivo). Nota: Esta fase no borra nada, solo clasifica.
  • Fase 2: Sweep (Barrer). Es el trabajo de contabilidad. V8 escanea la memoria linealmente buscando los objetos que no fueron marcados. En lugar de "destruirlos" físicamente, anota sus direcciones de memoria en una Free List (Lista de espacio libre) para que tu aplicación pueda sobrescribirlos con nuevos datos. El problema del Sweep es que deja "huecos" (fragmentación).
  • Fase 3: Compact (Compactar). Es el trabajo de mudanza. Si la memoria parece un queso gruyer lleno de huecos, V8 decide mover los objetos supervivientes para juntarlos en un solo bloque sólido. Esta es la operación más costosa para el CPU, porque si mueves un objeto, V8 tiene que actualizar todos los punteros de tu código que apuntaban a él. Por eso, V8 intenta evitar la compactación a menos que la fragmentación sea crítica.
Secuencia visual de heap fragmentado, barrido y compactación en memoria.
Después de Sweep, la memoria puede quedar llena de huecos dispersos; Compact reorganiza los bloques vivos para reducir fragmentación y convertir el espacio libre en una región continua más aprovechable.

6) ¿Qué es exactamente un Memory Leak en JavaScript?

Si le preguntas a un desarrollador junior qué es una fuga de memoria, te dirá que es "memoria olvidada que se acumula". Pero como acabamos de ver, el Garbage Collector es implacable: si algo está "olvidado" (inalcanzable), lo destruye.

La definición técnica correcta y precisa es esta: Un Memory Leak ocurre cuando un objeto ha perdido todo su valor práctico para la aplicación, pero sigue atado a una "raíz" mediante una cadena de referencias activa.

No confundas Leak con Bloat

Las herramientas de Chrome DevTools (y los manuales de Node.js) distinguen entre tres problemas de memoria muy diferentes:

  • Memory Leak (Fuga de memoria): La memoria de tu app crece progresivamente con el tiempo y nunca baja, incluso si el usuario deja de interactuar. Ocurre por referencias retenidas.
  • Memory Bloat (Hinchazón de memoria): Tu app consume una cantidad brutal de RAM (por ejemplo, cargando un JSON de 50MB de golpe), pero cuando terminas de procesarlo, la memoria se libera correctamente. No es una fuga, es un problema de optimización de arquitectura.
  • Frequent GC (Recolección frecuente): Creas y destruyes tantos objetos temporales por segundo que el Scavenger de la Generación Joven trabaja sin parar, causando tirones (jank) en la interfaz o picos de CPU en el servidor.

El leak es el más silencioso y letal de los tres. Todo lo que se necesita para tumbar un servidor Node.js de 16GB de RAM, o congelar la pestaña de Chrome de tu usuario, es un solo hilo brillante: un simple array global, un closure mal cerrado, o un evento que retenga la referencia de un objeto pesado.

Cadena de retención desde una raíz hasta un objeto final aún alcanzable.
Un memory leak no es memoria “olvidada”, sino memoria que permanece viva porque una cadena de referencias sigue conectándola con una raíz del programa.

7) Caso real 1: Detached DOM y el Event Listener eterno

Comencemos con el patrón de fuga de memoria más común en el frontend (muy frecuente en aplicaciones React (SPAs) o Vanilla JS complejas): el Detached DOM (DOM desprendido).

Imagina que tienes un modal o un componente visual pesado. Cuando el usuario lo cierra, tú ejecutas nodo.remove() o document.body.removeChild(nodo). Visualmente, el elemento desaparece de la pantalla. El problema es que "quitar del documento" no es lo mismo que "destruir de la memoria".

Si un Event Listener, una variable global, o un closure en JavaScript sigue apuntando a ese nodo, el Garbage Collector se cruza de brazos. El nodo se convierte en un "fantasma" (Detached DOM): no está en la pantalla, pero sigue vivo consumiendo RAM.

El código roto (El asesino silencioso)

Observa cómo creamos el leak más fácil del mundo:

// Variable global que actuará como "Raíz" retenedora
let nodoRetenido = null;

function crearYDestruirModal() {
  const modal = document.createElement('div');
  modal.id = 'mi-modal-pesado';
  
  // Simulamos un nodo muy pesado con muchos hijos
  modal.innerHTML = new Array(10000).fill('<span>Contenido pesado</span>').join('');
  
  document.body.appendChild(modal);

  // EL ERROR MORTAL:
  // Guardamos la referencia en una variable global o en un closure de larga vida
  nodoRetenido = modal; 
  
  // Simulamos que el usuario cierra el modal después de 2 segundos
  setTimeout(() => {
    // Lo quitamos de la vista (DOM Tree)
    modal.remove(); 
    console.log("Modal quitado de la pantalla. ¿Memoria liberada? NO.");
    // El recolector NO limpiará este nodo porque 'nodoRetenido' sigue apuntando a él.
  }, 2000);
}

crearYDestruirModal();
Árbol DOM con una rama desprendida que sigue retenida por una referencia JavaScript.
Un nodo puede salir del árbol DOM visible y aun así permanecer vivo si una referencia en JavaScript —por ejemplo, un listener o una variable— sigue apuntando a él.

La Solución: Romper la cadena

Para que el Garbage Collector pueda hacer su magia y destruir ese enorme bloque de HTML inútil, debemos asegurarnos de cortar la cuerda. Esto se logra limpiando las referencias explícitamente (estableciendo la variable a null) o removiendo el Event Listener.

let nodoRetenido = null;

function crearYDestruirModalSeguro() {
  const modal = document.createElement('div');
  modal.id = 'mi-modal-pesado';
  modal.innerHTML = new Array(10000).fill('<span>Contenido pesado</span>').join('');
  
  document.body.appendChild(modal);
  nodoRetenido = modal; 
  
  setTimeout(() => {
    modal.remove(); // 1. Quitamos del DOM visual
    
    // 2. LA SOLUCIÓN: Rompemos la referencia JS
    nodoRetenido = null; 
    
    console.log("Modal quitado de la pantalla Y referencia limpiada. El GC ahora sí puede barrerlo.");
  }, 2000);
}

crearYDestruirModalSeguro();

8) Caso real 2: El Interval olvidado y la trampa del Closure

El segundo asesino silencioso de la memoria es el setInterval mal gestionado. Cuando creas un temporizador, la API del navegador (o de Node.js) guarda una referencia oculta a la función callback que le pasaste.

El problema es que esa función callback es un Closure: tiene acceso al estado y a las variables del entorno donde fue creada. Si el temporizador sigue corriendo, el closure sigue vivo, y por lo tanto, cualquier variable pesada a la que haga referencia estará blindada contra el Garbage Collector.

El código roto (El bucle infinito)

Imagina un componente o una vista de tu aplicación que se monta y desmonta:

function iniciarDashboard() {
  // Simulamos datos masivos que consume el dashboard
  const datosPesados = new Array(1000000).fill('Métrica'); 

  // Iniciamos una actualización periódica cada 3 segundos
  setInterval(() => {
    // El closure atrapa a 'datosPesados' para poder leerlo
    console.log("Actualizando UI con", datosPesados.length, "registros...");
  }, 3000);
  
  console.log("Dashboard iniciado.");
}

iniciarDashboard();
// El usuario navega a otra pantalla. La vista del dashboard se destruye visualmente.
// PERO el setInterval sigue vivo. 'datosPesados' jamás será recolectado.
Bucle repetitivo que mantiene vivo un bloque central de estado en memoria.
Un setInterval o ciclo repetitivo puede seguir reteniendo estado y contexto aunque ya no aporten valor, impidiendo que el recolector de basura libere esa memoria.

La Solución: Desactivar la bomba de tiempo

La regla de oro del desarrollo web: Todo lo que tiene un inicio, debe tener un final. Siempre que inicies un proceso asíncrono repetitivo, debes guardar su identificador y destruirlo explícitamente cuando el componente o la vista ya no se necesiten.

function iniciarDashboardSeguro() {
  const datosPesados = new Array(1000000).fill('Métrica'); 

  // 1. Guardamos el ID del timer
  const timerId = setInterval(() => {
    console.log("Actualizando UI con", datosPesados.length, "registros...");
  }, 3000);
  
  // 2. Simulamos la destrucción de la vista
  return function destruirDashboard() {
    // Rompemos el ciclo. Ahora el closure muere y 'datosPesados' es recolectado.
    clearInterval(timerId);
    console.log("Dashboard destruido. Timer detenido. Memoria liberada.");
  };
}

const limpiar = iniciarDashboardSeguro();

// Cuando el usuario cambie de página:
limpiar();

9) Caso real 3: La Caché Infinita (El asesino de servidores)

A diferencia de los casos anteriores donde el error era "olvidar" una referencia, este tercer patrón es una fuga de memoria intencional pero ingenua. Ocurre cuando guardamos datos en memoria para mejorar el rendimiento de la aplicación, pero olvidamos establecer un límite de crecimiento.

En JavaScript, estructuras como objetos literales ({}) o Map() son perfectas para crear cachés rápidas. El problema es que un Map retiene fuertemente las claves y los valores que insertas en él. Si tu aplicación recibe peticiones constantemente y guardas un registro por cada petición sin una política de limpieza, el Map crecerá hasta devorar toda la RAM disponible.

Estás viendo solo el 60% del contenido. ¡Únete a la Membresía Premium! para acceder al contenido completo.

← Volver a Guías

Comentarios y Valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!