Buscando fugas de memoria en JavaScript: Alcanzabilidad, retenedores y limpieza
Aprende por qué el JavaScript con recolección de basura sigue presentando fugas de memoria, qué patrones cotidianos retienen memoria y cómo encontrar al culpable mediante capturas del heap y cadenas de retención.
Una aplicación web que parece rápida a las 9 de la mañana pero lenta a las 5 de la tarde es uno de los síntomas más comunes de una fuga de memoria: los clics responden con retraso, el desplazamiento pierde su fluidez, las animaciones se interrumpen, el uso de RAM supera los mil millones de bytes y volver a cargar la página arregla todo. Este tipo de fugas no generan excepciones, no fallan en las pruebas y pasan sin problemas por los procesos de integración continua, ya que solo se manifiestan cuando alguien mantiene la aplicación abierta durante horas. Esta guía explica por qué incluso los lenguajes con recolección de basura presentan fugas, analiza los patrones que causan la mayoría de las fugas en el mundo real y ofrece un flujo de trabajo reproducible con Chrome DevTools para encontrar la referencia exacta que mantiene viva la memoria.
La recolección de basura libera lo inalcanzable, no lo que no se utiliza
Dado que JavaScript nunca le pide que llame a malloc() o free(), es tentador pensar que el entorno de ejecución se encarga por completo de la memoria. Esa creencia solo es parcialmente correcta. El recolector no recupera un objeto cuando su código ya no lo utiliza; lo recupera cuando ya nada puede acceder a él. Si alguna referencia olvidada sigue apuntando a un objeto, el motor no tiene forma de saber que ese objeto es inútil. Desde el punto de vista del entorno de ejecución, cualquier cosa a la que se pueda acceder podría seguir siendo necesaria.
Esa brecha entre “ya no se utiliza” y “ya no se puede acceder” es donde residen algunos de los errores de rendimiento más difíciles en el desarrollo web moderno.
Un escenario: el panel de control que ralentizaba todo por la tarde
Imagínese un panel de control operativo que el personal mantiene abierto durante toda la jornada. En pruebas de calidad, su rendimiento es rápido: carga inicial rápida, llamadas a API eficientes y altas puntuaciones en Lighthouse. Pero luego llegan los comentarios desde el entorno de producción. Después de cinco o seis horas, cambiar de pestaña se vuelve lento, los gráficos se actualizan con lentitud e incluso un modal sencillo tarda un tiempo considerable en abrirse.
La reacción típica es culpar al backend. El equipo ajusta las consultas a la base de datos, se asegura de que las respuestas de la API permanezcan por debajo de 100 milisegundos y observa un bajo uso de CPU en los servidores. Nada de esto explica la ralentización que aumenta a lo largo del día.
El avance proviene del Administrador de tareas de Chrome. La pestaña comenzó la mañana con aproximadamente 150 MB y alcanzó casi 1,4 GB hacia el final de la tarde. Cada navegación, cada diálogo y cada actualización de widget dejaron algo de memoria en el sistema. Cada asignación de memoria era pequeña; pero miles de ellas no lo eran. El rendimiento, la latencia de red y la velocidad de ejecución en sí estaban bien. La aplicación simplemente nunca liberaba los objetos que ya no necesitaba.
Cómo deciden los motores qué permanece activo
Crear un objeto en JavaScript no requiere ningún procedimiento especial:
const user = {
id: 101,
name: "Emma"
};
Cuando ya nada hace referencia a user, el motor puede recuperarlo libremente. Lo interesante es cómo toma esa decisión. V8 (Chrome y Node.js), SpiderMonkey (Firefox) y JavaScriptCore (Safari) rastrean un grafo de objetos conectados mediante referencias. Las raíces, como el objeto global, se encuentran en la parte superior, y todo lo que construye tu aplicación depende de ellas: la instancia de la aplicación, el enrutador, el almacén, los árboles de componentes y cualquier variable global.
Un ciclo de recolección comienza en esas raíces y sigue todas las referencias posibles. Todo a lo que llegue sobrevive; todo a lo que no pueda llegar pasa a ser apto para su eliminación. La palabra clave es apto, y la propiedad clave es inaccesible. Un objeto no se recoge porque sea antiguo, esté sin usar o haya sido olvidado. Se recoge únicamente porque no existe ningún camino de referencias que lo conduzca.
Supongamos que carga una lista grande de registros:
const employees = fetchEmployees();
Algún tiempo después, la interfaz de usuario ya no muestra esos datos, pero otro objeto sigue apuntando a ellos:
cache.employees = employees;
Incluso si nadie vuelve a leer cache.employees, el recolector no puede asumirlo. La referencia existe, por lo que todo el array permanece en memoria. El motor se comporta exactamente como fue diseñado; la fuga proviene del código de la aplicación.
Pensar en referencias en lugar de objetos
Las fugas se vuelven mucho más fáciles de comprender una vez que deja de preguntarse por los objetos y comienza a hacerlo por las referencias que apuntan a ellos. Tome una función que crea un objeto y lo devuelve:
function createUser() {
const user = {
name: "Alice"
};
return user;
}
const employee = createUser();
Después de que se ejecute, una única referencia conecta la variable con el objeto:
employee
│
▼
{ name: "Alice" }
Si luego elimina esa referencia, el objeto no tiene conexiones entrantes y la siguiente recopilación puede eliminarlo:
employee = null;
Una corrección si lo pruebas tú mismo: employee fue declarado con const en el fragmento anterior, por lo que volver a asignárselo genera un TypeError. Decláralo con let cuando pretendas eliminar la referencia más tarde. El punto sigue siendo el mismo: es al eliminar la última referencia cuando el objeto se vuelve recogible.
Ahora modifica ligeramente el ejemplo para que la función almacene lo que crea en un array a nivel de módulo:
const users = [];
function createUser() {
const user = {
name: "Alice"
}; users.push(user);
}
Una vez que createUser() devuelve, cada objeto de usuario sigue teniendo una referencia a través del array users, y el propio array es accesible desde el nivel superior:
Window
│
▼
users
│
├── User 1
├── User 2
├── User 3
└── User 4
Mientras users siga siendo accesible, también lo estarán todos sus elementos. Por esta razón las fugas de memoria suelen aumentar gradualmente: ningún objeto individual es grande, pero miles de objetos pequeños se acumulan a lo largo de horas o días.
Las fugas de memoria se acumulan una interacción a la vez
La expresión “fuga de memoria” tiende a hacer pensar en un objeto enorme que consume cientos de megabytes. En la práctica, el patrón es casi siempre pequeño y repetitivo.
Imagínese que al cerrar un diálogo de configuración se dejan atrás aproximadamente 20 KB. Eso es insignificante por sí solo. Un usuario avanzado que abre y cierra ese diálogo 500 veces en un día laboral ya ha perdido alrededor de 10 MB. Si se suman cinco componentes con fugas similares de pequeña magnitud por interacción, una sesión de ocho horas, varias pestañas abiertas al mismo tiempo y actualizaciones en vivo que llegan cada few segundos, esas cantidades insignificantes se convierten en cientos de megabytes.
Esa lógica también explica por qué los desarrolladores rara vez se dan cuenta. Durante el desarrollo se vuelve a cargar la página cada few minutos, lo que borra todo rastro. Los usuarios no vuelven a cargarla; siguen trabajando.
Por qué las aplicaciones de página única lo sienten más
Los sitios clásicos de múltiples páginas contaban con una red de seguridad accidental: cada navegación cargaba un documento nuevo y descartaba todo el heap de JavaScript, incluidos los objetos que causaban fugas.
Las aplicaciones de una sola página desarrolladas con React, Angular, Vue, Svelte o frameworks similares pueden funcionar durante horas sin una recarga completa. Eso es excelente para la experiencia del usuario y precisamente por eso es importante tener disciplina con la memoria. Cada cambio de ruta, modal, notificación, mensaje de WebSocket y actualización de gráficos crea objetos. Si no se liberan adecuadamente, permanecen activos mientras dure la página.
Hay una ironía aquí: cuanto mejor sea la experiencia, más tiempo permanecen los usuarios, y mayor es la posibilidad de que pequeñas fugas de memoria se acumulen.
El recolector es sofisticado, no vidente
Los motores modernos utilizan recolección incremental y generacional, marcado concurrente, compactación y recolección en tiempos de inactividad. Estas técnicas hacen que la gestión de la memoria sea rápida y discreta, pero no pueden corregir errores lógicos.
Imagínese prestar un libro a alguien sin pedirlo de vuelta. Esa persona no puede saber que usted lo ha olvidado, así que, para ella, usted sigue queriendo que se le devuelva algún día. Las referencias funcionan de la misma manera. Mientras su solicitud mantenga una referencia, el motor asume que el objeto es importante, independientemente de si su código volverá a acceder a él o no. No puede leer la intención; solo sigue los enlaces del grafo.
Ese cambio modifica la forma en que depura. En lugar de preguntarse por qué el recolector no libera memoria, hace una pregunta más útil: ¿qué sigue teniendo una referencia a este objeto? Casi siempre allí está el error.
Los patrones detrás de la mayoría de las fugas en el mundo real
El colector no está dañado; las fugas ocurren porque el código mantiene referencias que debería haber eliminado. Esas referencias rara vez parecen sospechosas. Proviene de código común y de apariencia normal, y no de algoritmos exóticos o errores del navegador. Los patrones siguientes abarcan las fugas que es más probable encontrar en entornos de producción.
Escuchadores de eventos que nunca se eliminan
Los escuchadores son uno de los culpables más frecuentes, especialmente en SPAs. La configuración suele ser inocente: se toma un elemento y se le asigna un manejador.
const button = document.getElementById("save");
button.addEventListener("click", saveDocument);
Más tarde, el usuario abandona la página y el botón desaparece. Eliminar un elemento del DOM por sí solo no elimina todas las referencias a JavaScript relacionadas. Si el manejador sigue registrado y algo más mantiene al elemento o al manejador accesible, tanto el oyente como lo que este referencia permanecen en memoria. La fuga es más grave cuando los oyentes están asociados a objetos de larga duración como window o document, ya que esos objetos nunca desaparecen. La solución es cancelar el registro del manejador de forma explícita:
button.removeEventListener("click", saveDocument);
En React, Angular o Vue, haga esto en la fase de desmontaje o destrucción del ciclo de vida del componente. Un hábito útil es tratar cada llamada a addEventListener() como un compromiso: cuando agrega uno, debe saber cuándo y dónde se eliminará. Pasar una señal AbortController a varios listeners y abortarla una vez al desmontar es una forma práctica de cumplir con ese compromiso de manera masiva.
Timer que sobreviven a su pantalla
Los timers también causan fugas de forma silenciosa. Una pantalla de control que consulta datos nuevos cada cinco segundos podría verse así:
const timer = setInterval(() => {
loadLatestData();
}, 5000);
Si el usuario abandona la página y el intervalo nunca se elimina, la función de callback sigue ejecutándose en segundo plano. Esto mantiene viva su cierre, junto con todas las funciones, variables e incluso instancias completas de componentes a las que ese cierre hace referencia. Unos pocos intervalos olvidados pueden ocupar mucho más memoria de lo que uno esperaría. Elimínelos cuando su propietario se vaya:
clearInterval(timer);
La misma disciplina se aplica a setTimeout() (a través de clearTimeout()) y a requestAnimationFrame() (a través de cancelAnimationFrame()).
Nodos DOM desacoplados
Un nodo desacoplado es un elemento que ya no forma parte del documento pero que sigue siendo referenciado desde JavaScript. Tomar un modal y eliminarlo es una forma típica de crear uno:
const modal = document.getElementById("modal");
modal.remove();
Parece que ha desaparecido, pero si alguna variable, arreglo, cierre o objeto de estado sigue apuntando a ese elemento, no se puede recoger, al igual que sus hijos. Las aplicaciones que crean dinámicamente modales, tooltips, menús desplegables o paneles de notificaciones son especialmente propensas a esto. Cada nodo es pequeño, pero después de cientos de interacciones, los subárboles separados pueden ocupar una cantidad sorprendente de memoria.
Cierres que capturan más de lo necesario
Los cierres son una de las características más poderosas de JavaScript, y también hacen que sea fácil conservar memoria por accidente. Considere una fábrica que asigna un arreglo grande antes de devolver una función:
function createLogger() {
const largeData = new Array(100000).fill("data");
return function () {
console.log("Logging...");
};
}
La función devuelta nunca accede a largeData. Que ese array permanezca activo depende de cómo el motor representa el ámbito contenedor. En la práctica, V8 solo mantiene las variables a las que alguna cierre en ese ámbito hace referencia realmente, por lo que este fragmento concreto generalmente no conserva el array. El riesgo surge cuando una segunda cierre creada en el mismo ámbito sí utiliza largeData: las cierres comparten un mismo objeto de contexto, por lo que el registrador de larga duración acaba manteniendo también el array grande activo. Usar eval dentro del ámbito también obliga al motor a conservarlo todo.
Nada de esto hace que los cierres sean algo negativo; el JavaScript moderno depende de ellos. La lección es ser deliberado respecto a lo que puede ver una función de larga duración. Si solo necesita un valor, páselo o copíe ese valor en lugar de cerrarse sobre todo un objeto o conjunto de datos. Pequeños ajustes al ámbito pueden reducir significativamente el uso de memoria.
Cachés sin política de eliminación
El cacheo ahorra trabajo repetido, pero un caché que solo crece es simplemente una fuga lenta con buenas intenciones. Aquí hay un ejemplo mínimo de búsqueda con memorización:
const cache = {};
function getUser(id) {
if (!cache[id]) {
cache[id] = fetchUser(id);
} return cache[id];
}
Al principio funciona bien. Después de seis meses en producción, puede contener cientos de miles de entradas que nadie volverá a solicitar. En lugar de permitir que un caché crezca sin límites, considere:
- Un tamaño máximo
- Vencimiento basado en el tiempo para las entradas obsoletas
- Una estrategia de eliminación LRU (menos utilizado recientemente)
WeakMap cuando la caché está indexada por objetos cuya vida útil debe determinar la vida útil de la entradaTenga en cuenta que WeakMap solo acepta objetos (o símbolos no registrados) como claves, por lo que no reemplaza a un algoritmo LRU para cachés indexadas por IDs numéricos como el mencionado anteriormente. Si desea repasar cómo se comportan las referencias débiles, consulte nuestro resumen sobre Símbolos, WeakMaps, Proxies y generadores. Una caché sin estrategia de eliminación en realidad no es una caché; es un almacenamiento permanente.
Variables globales que existen para siempre
Cualquier elemento vinculado al ámbito global permanece activo mientras dure la aplicación. Esto es conveniente, pero también representa un riesgo en igual medida. Una colección a nivel de módulo como esta:
let allUsers = [];
sigue creciendo cada vez que se añaden nuevos datos:
allUsers.push(...newUsers);
A menos que algún código lo elimine explícitamente, el array solo sigue creciendo. Con los meses de desarrollo, los objetos globales grandes tienden a convertirse en lugares donde se acumula datos. Al investigar un problema de rendimiento, el estado global es uno de los primeros lugares que vale la pena revisar.
WebSockets y otras conexiones de larga duración
Las funciones en tiempo real suelen depender de WebSockets, y abrir uno solo requiere una línea:
const socket = new WebSocket(url);
El error consiste en no cerrarlo. Un socket abierto sigue recibiendo mensajes, ejecutando callbacks y manteniendo el estado de la aplicación incluso después de que el usuario se haya ido a otra parte. Cierre las conexiones cuando la función que las utiliza ya no esté en uso:
socket.close();
La misma regla aplica a los observables, flujos, emisores de eventos personalizados y cualquier otra suscripción que pueda sobrevivir a su consumidor.
El hilo común
A primera vista, estos ejemplos parecen diferentes, pero comparten una misma causa raíz: algo mantiene una referencia a un objeto que debería haberse vuelto inalcanzable. Eso suele ser uno de los siguientes:
- Un oyente registrado
- Un intervalo o tiempo de espera pendiente
- Un ámbito de cierre
- Una caché en constante crecimiento
- Una variable a nivel de módulo o global
- Un socket abierto u otra suscripción
Cuando piensas en referencias en lugar de objetos, la búsqueda de fugas se vuelve mucho más intuitiva. En lugar de preguntarte por qué la memoria sigue aumentando, pregunta qué es lo que aún mantiene esa referencia al objeto. Esa pregunta suele llevar directamente hasta la fuga.
Encontrar la fuga antes de que lo hagan tus usuarios
Saber las causas es útil, pero en un código real la pregunta clave es simplemente dónde está la fuga. Una aplicación grande puede tener miles de componentes y cientos de listeners, con objetos que se crean cada segundo. Adivinar rara vez funciona. Las herramientas del navegador son excelentes; lo que marca la diferencia es usarlas de manera consistente y disciplinada, en lugar de intentar aprender todas las funciones de DevTools.
Confirme que realmente hay una fuga
Un aumento en la memoria no significa automáticamente que haya una fuga. Los motores de ejecución asignan memoria a medida que la aplicación funciona y recuperanla durante las operaciones de recolección, por lo que una aplicación sana muestra un patrón en forma de diente de sierra:
Memory
^
| /\ /\ /\
| / \ / \ / \
|______/____\__/____\___/____\____ Time
El uso de memoria aumenta durante las actividades y disminuye después de cada recolección. Una aplicación con fuga muestra un patrón diferente:
Memory
^
| /\ /\
| / \ / \
| / \ / \
|_______/______\__/______\________
| /
| /
| /
|___________/________________ Time
Las pequeñas caídas indican que el recolector está en funcionamiento, pero cada valle se encuentra a un nivel más alto que el anterior. La memoria nunca vuelve a su valor de referencia inicial, lo cual es la primera señal real de que se están reteniendo objetos. Antes de sacar conclusiones, active manualmente la recolección (el ícono de papelera en los paneles de Memoria y Rendimiento), ya que un valor de referencia que solo parece alto porque aún no se ha ejecutado la recolección no constituye una fuga de memoria.
Paso 1: abrir el panel de Memoria
Chrome ofrece varias herramientas relacionadas con la memoria, pero no es necesario utilizarlas todas al mismo tiempo. Abra DevTools y cambie al panel Memoria. Dependiendo de la versión de Chrome, verá tipos de análisis como una captura del heap, instrumentación de asignaciones en una línea de tiempo y muestreo de asignaciones; los nombres y opciones exactos varían entre las versiones, así que consulte la documentación actual de DevTools si los suyos difieren.
Para la mayoría de las investigaciones, una instantánea del heap es el punto de partida adecuado, ya que muestra qué ocupa actualmente la memoria.
Paso 2: registrar un estado inicial
Antes de utilizar la función sospechosa, tome una instantánea del estado inicial de la aplicación, como una fotografía del heap. Luego realice repetidamente la interacción que sospecha. Por ejemplo:
- Abra y cierre un modal diez veces
- Navegue de ida y vuelta entre páginas
- Ejecute la carga de un archivo
- Aplique filtros a una tabla de grandes volúmenes de datos
- Cambie repetidamente entre pestañas del panel de control
Cuando haya terminado, tome una segunda instantánea. Ahora tiene dos estados para comparar.
Paso 3: comparar las instantáneas
Aquí es donde realmente comienza la investigación. Si la limpieza funciona, los objetos temporales creados durante la interacción deberían desaparecer después de su recolección. Si no es así, ciertos tipos de objetos siguen aumentando. Los sospechosos típicos incluyen:
- Elementos DOM separados
- Arreglos grandes
- Escuchadores de eventos
- Las clases de tu propia aplicación
- Componentes del framework que deberían haber sido destruidos
No es necesario que entiendas cada elemento del heap. Utiliza la vista de comparación y busca aquellos tipos de objetos cuya cantidad aumenta en una cantidad constante cada vez que repitas la misma acción. La consistencia suele ser la pista más fuerte.
Los nodos DOM separados son la evidencia más fácil de detectar
Los nodos separados son una de las fugas más fáciles de reconocer. Abre y cierra un modal veinte veces; después de cada cierre, ese modal debería desaparecer. Si la captura aún contiene veinte elementos modales, algo los mantiene activos. Puedes escribir “Detached” en el filtro de clases de la captura para listarlos rápidamente.
Rara vez el DOM en sí es el verdadero problema. La causa real suele estar en otro lugar:
- Un manejador aún registrado en el elemento o en un destino de larga duración
- Un intervalo o tiempo de espera cuya función de callback menciona al nodo
- Una clausura que capturó el elemento
- Un almacén, campo de componente o array que guardó un puntero hacia él
Considera el nodo separado como un síntoma: prueba de que alguna otra referencia impidió su eliminación.
Sigue la cadena de retención
Una vez que encuentres un objeto que claramente ya no debería existir, la siguiente pregunta es quién lo mantiene activo. La sección Retainers de una captura de estado del heap responde exactamente a eso. Selecciona el objeto y Chrome muestra la cadena de referencias que lo conectan con una raíz del GC. Conceptualmente, podría verse así:
Window
│
Application
│
UserService
│
cachedUsers
│
User Object
La investigación ahora se vuelve sencilla. En lugar de preguntarse por qué un objeto del usuario persiste, puedes ver que es referenciado por una colección cachedUsers dentro de UserService. Localizar el objeto retenido es útil; identificar qué lo mantiene es lo que realmente soluciona el error.
Observa contadores en tiempo real con el Monitor de rendimiento
Las capturas de estado son ideales para un análisis detallado, pero no son la única herramienta. El Monitor de rendimiento de Chrome muestra métricas en tiempo real que incluyen:
- Tamaño del heap de JS
- Número de nodos DOM
- Número de oyentes de eventos de JS
- Documentos y marcos
Si el número de nodos DOM o oyentes sigue aumentando al repetir la misma acción, no se está realizando la limpieza necesaria. La ventaja es la velocidad: no es necesario esperar a que la aplicación se vuelva lenta, ya que las tendencias sospechosas suelen hacerse visibles en pocos minutos.
Aíslar un escenario pequeño y repetible
Un error frecuente es intentar investigar toda la aplicación al mismo tiempo. En su lugar, concéntrate en una sola interacción:
- Mostrar un modal único, cerrarlo y repetirlo veinte veces seguidas
- O bien, alternar entre las mismas dos rutas cincuenta veces
Los escenarios complejos como estos son mucho más fáciles de cuantificar. Cuando una acción repetida genera crecimiento en cada iteración, ya se ha reducido drásticamente el rango de búsqueda, y por lo general es sencillo encontrar el código responsable a partir de ahí.
Pruebe sesiones largas intencionadamente
Los desarrolladores tienden a probar una aplicación durante diez o quince minutos y pasar a otra cosa. Los usuarios reales de un panel interno, plataforma de trading, herramienta de monitoreo o portal de soporte pueden mantenerla abierta todo el día. Incluya sesiones prolongadas en sus pruebas de memoria: deje la aplicación abierta, interactúe con ella periódicamente y observe cómo evoluciona la memoria. Muchas fugas de memoria solo se vuelven visibles después de cientos o miles de interacciones.
Un flujo de trabajo para depurar que evita conjeturas
Pasar de un perfilador a otro es una pérdida de tiempo. Una secuencia establecida suele funcionar mejor:
- Confirme que la memoria sigue aumentando a lo largo de las colecciones.
Esto elimina las conjeturas del proceso. En lugar de suponer que un componente en particular es el culpable, dejas que las pruebas te guíen hasta él.
Prevenir fugas antes de que lleguen a producción
El diagnóstico es solo la mitad de la historia; la solución más económica es evitar las fugas desde el principio. La mayoría de las fugas no se deben a que los desarrolladores malentendan JavaScript. Ocurren porque las aplicaciones modernas tienen una vida útil prolongada, son altamente interactivas y asignan recursos constantemente, y en ese entorno es fácil olvidar que todo lo que se crea eventualmente necesita ser eliminado. Los equipos que rara vez enfrentan problemas de memoria no necesariamente están escribiendo código más inteligente; tienen hábitos que hacen poco probable la aparición de fugas.
Asignar un final explícito a cada recurso
Cada vez que el código cree algo con vida útil prolongada, haga una pregunta: ¿cuándo será destruido? Esto aplica a mucho más que solo la memoria bruta:
- Escuchadores en elementos,
windowodocument - Intervalos, tiempos de espera y fotogramas de animación
- Sockets y otras conexiones de red
Configurarlos suele ser sencillo; lo difícil es eliminarlos, y ahí es donde las aplicaciones fallan. Una regla simple lo resume: si tu código tiene un inicio, también necesita un final. Solo ese enfoque ya evita una cantidad sorprendente de fugas de recursos.
Hacer que los componentes se limpien solos
Los frameworks basados en componentes fomentan unidades autónomas, y estas deberían incluir mecanismos de desmontaje. Un componente que inicia un temporizador lo detiene al ser desmontado. Un componente que registra listeners los elimina. Nada de lo que un componente inició debe seguir funcionando una vez esté fuera de la pantalla.
Imagínese salir de una habitación de hotel: no se va dejando las luces encendidas, el televisor encendido ni el grifo abierto. Un componente bien diseñado deja todo tal como lo encontró. En React, eso significa devolver una función de limpieza desde cada useEffect que se suscriba, programe o se conecte.
Diseñe cachés pensando en la eliminación, no solo en la inserción
Los cachés nacen con buenas intenciones, evitando llamadas repetidas a la API o cálculos costosos. Meses después pueden contener miles de objetos que nadie ha consultado en semanas. Al diseñar uno, piense en cómo las entradas deben salir con la misma atención que cuando llegan:
- ¿Cuánto tiempo debe permanecer una entrada en memoria?
- ¿Cuál es el tamaño máximo?
- ¿Deben caducar las entradas automáticamente?
- ¿Se pueden eliminar las entradas que se usan raramente?
Si no hay respuestas a esas preguntas, casi con certeza el caché crecerá con el tiempo.
Mantenga solo los datos que realmente necesita
Otro problema frecuente es conservar objetos completos cuando solo se utiliza una pequeña parte de ellos. Si carga un perfil grande únicamente para mostrar un nombre de usuario, no hay razón para guardar toda la respuesta indefinidamente; almacene solo los campos que necesita la interfaz de usuario. Los objetos más pequeños consumen menos memoria, son más fáciles de gestionar y es menos probable que se conserven por error. A veces la solución no consiste en escribir más código, sino en almacenar menos datos.
Tome en serio las pequeñas fugas de memoria
Es tentador ignorar una fuga de unos pocos kilobytes. El problema es que los usuarios rara vez realizan algo solo una vez. Una pantalla de control que se muestra todo el día, una herramienta administrativa interna compartida por cientos de empleados o un panel de monitoreo que nadie vuelve a cargar ejecutan los mismos caminos de código miles de veces. Una fuga que hoy apenas se puede medir puede convertirse en un verdadero problema después de semanas de uso normal.
Añada verificaciones de memoria al desarrollo diario
El trabajo relacionado con el rendimiento suele centrarse en el tiempo de carga y la latencia de las API, pero la memoria también merece atención. Al desarrollar una nueva función, dedique unos minutos adicionales a verificar lo siguiente:
- ¿La memoria vuelve a su nivel base después de usar la función?
- ¿El número de oyentes está aumentando de forma inesperada?
- ¿Los nodos DOM desaparecen una vez que se eliminan los componentes?
- ¿Repetir la misma acción aumenta constantemente el uso de memoria?
Estas verificaciones son sencillas y pueden ahorrar horas de depuración posteriormente.
Lista de verificación para la revisión de código
Antes de fusionar, revise mentalmente una breve lista:
- ¿Todos los oyentes de eventos que se añadieron también se eliminan?
- ¿Los temporizadores se cancelan una vez que ya no son necesarios?
- ¿Las suscripciones se eliminan correctamente?
- ¿Es posible que este caché crezca sin límite?
No es necesario aplicar esto a cada línea, pero hacerlo parte de la revisión reduce significativamente las posibilidades de enviar un producto con fugas.
Puntos clave
- Rara vez las fugas causan un colapso el primer día; se van acumulando silenciosamente y afectan primero a los usuarios más activos, lo que las hace peligrosas.
- También son predecibles: un objeto permanece activo solo porque aún hay alguna referencia a él, por lo que cada fuga se debe a una referencia que debería haberse eliminado.
- Cuando una aplicación se ralentiza con el paso de las horas, evite culpar al navegador o al motor. Tome una captura del heap, encuentre los objetos retenidos y siga la cadena de retención.
- La respuesta habitual es algo cotidiano: un oyente olvidado, un temporizador sin cancelar, una caché ilimitada o un componente que nunca terminó de limpiarse.
- Un JavaScript rápido no se trata solo de la velocidad de ejecución; se trata también de gestionar el ciclo de vida de lo que se asigna, para que la aplicación siga siendo receptiva ya sea que alguien la use durante cinco minutos o todo un día laboral.
Lecturas relacionadas
- Fugas de memoria en React Native: Rastreo del heap JS y propietarios de la memoria nativa — Aprenda por qué la recolección de basura no puede salvar a una aplicación React Native de las fugas nativas, y cómo descubrir qué mantiene activos los callbacks, los objetos JSI y las imágenes decodificadas.
- El caché de mapas de fuente de Node es una fuga de memoria silenciosa en el modo dev — Aprenda por qué habilitar --enable-source-maps o NODE_V8_COVERAGE puede causar un crecimiento ilimitado del heap debido a llamadas repetidas a eval, y cómo diagnosticarlo y mitigarlo hoy mismo.