Fuga de memoria en Node.js: encuentra manejadores, cachés y temporizadores descontrolados
Localiza una fuga de memoria en Node.js identificando listeners, cachés y temporizadores descontrolados, y demuestra la corrección con instantáneas del heap y pruebas reproducibles.

Qué parece una fuga de memoria en una aplicación Node.js
Una fuga de memoria en Node.js ocurre cuando tu app sigue reteniendo memoria que ya no necesita. El detalle clave es que el uso de memoria sube con el tiempo y nunca vuelve completamente a la normalidad, incluso después de que el trabajo que causó el pico ha terminado.
En producción suele aparecer como una subida lenta: todo funciona bien durante minutos u horas, luego las respuestas se hacen más lentas, el proceso empieza a ejecutar el recolector de basura con más frecuencia y, finalmente, la app se reinicia o se bloquea con un error de out-of-memory. Si observas métricas básicas, puede que veas RSS y uso del heap con una tendencia al alza a través de despliegues, ciclos de tráfico o jobs en segundo plano.
Los signos comunes incluyen:
- La memoria sube tras cada petición o ejecución de job
- Las pausas del GC se alargan y son más frecuentes
- Los reinicios se vuelven previsibles (por ejemplo, cada noche tras un batch)
- Los contenedores alcanzan límites de memoria aun con tráfico normal
- La app se vuelve más lenta sin un pico obvio de CPU
No todo lo que sube es una fuga. Parte del crecimiento es normal: una caché que se calienta, un paso de compilación único, o un pico de tráfico que baja cuando la carga se reduce. Además, algunas “fugas” son en realidad cachés intencionados que son demasiado grandes o no tienen límites. La diferencia es si los niveles de memoria se estabilizan. Una app sana puede subir y luego quedarse en torno a una línea base. Una app con fuga sigue marcando nuevas líneas base.
Un ejemplo simple: un prototipo añade un listener de evento en cada request pero nunca lo elimina. Cada request “termina”, pero el listener mantiene una referencia a datos de esa petición. La memoria sube un pequeño paso cada vez hasta que se convierte en un problema serio.
Para arreglar fugas sin adivinar necesitas dos cosas: una forma reproducible de provocar el crecimiento y una manera de demostrar que la corrección funcionó. Esa prueba suele ser que la memoria se estabiliza y las instantáneas del heap muestran que los objetos que crecían ya no crecen.
Triage rápido: confirma que persigues el problema correcto
Antes de buscar una fuga de memoria en Node.js, asegúrate de que la app realmente tiene una fuga (la memoria sube y se mantiene alta), y no sólo está manejando un pico temporal (la memoria sube y luego baja tras el GC).
Empieza observando varios números juntos. Una sola métrica puede engañarte, especialmente en código prototipo donde los “arreglos rápidos” suelen ocultar la causa real.
- Process memory (RSS): memoria total que el SO dice que usa el proceso.
- Heap used: objetos JavaScript gestionados por V8.
- Event loop lag: si sube, la app se está quedando bloqueada haciendo trabajo o el GC está thrashing.
- Request latency: las fugas suelen aparecer como respuestas más lentas con el tiempo.
- Error rate / timeouts: una “fuga” a veces es una tormenta de reintentos o jobs atascados.
Luego diferencia “crecimiento del heap” de “memoria nativa” a alto nivel. Si heap used sigue subiendo durante varios minutos con carga estable, probablemente estás reteniendo objetos JS (manejadores, cachés, closures, arrays, Maps). Si RSS crece pero heap used permanece mayormente plano, sospecha memoria fuera del heap de JS: Buffers grandes, streams sin cerrar, add-ons nativos, o incluso una librería de logging/metrics que acumula datos.
Las fugas que van de prototipo a producción suelen venir de los mismos hábitos: singletons globales que acumulan estado, cachés sin límite de tamaño, listeners añadidos por petición y timers iniciados sin ruta de parada. Aparecen mucho en apps generadas o fuertemente modificadas por herramientas de IA, donde el código “funciona una vez” pero carece de limpieza.
Ponte un objetivo simple antes de tocar código: reproducir el crecimiento en minutos, no en días. Elige una ruta o job que dispare el problema, aplica carga constante y define “éxito” como: después de empezar la prueba, la memoria aumenta en un patrón reproducible (por ejemplo, +20 MB cada 2 minutos). Una vez tengas eso, podrás demostrar la corrección más tarde.
Si heredaste un prototipo desordenado y no puedes ni siquiera obtener medidas estables, ese es exactamente el punto donde una auditoría rápida (como las que hace FixMyMess) puede identificar los mayores riesgos de retención antes de reescribir media aplicación.
Haz la fuga reproducible antes de tocar el código
Una fuga de memoria en Node.js es difícil de arreglar si sólo aparece “a veces”. Antes de cambiar nada, convierte el problema en una acción reproducible que haga subir la memoria a voluntad.
Empieza por elegir un disparador que coincida con el uso real. Buenos candidatos son una única ruta HTTP, el tick de un job en segundo plano, un ciclo de conexión/desconexión WebSocket o una tarea de importación. Escoge la acción más pequeña que aún cause la subida.
Luego repite ese disparador en bucle. Puedes hacerlo manualmente (recargar la misma página 50 veces), pero un script pequeño es mejor porque elimina la variación humana. El objetivo no es hacer load testing; es consistencia.
Mantén las variables estables mientras lo reproduces:
- Usa la misma cuenta de usuario y permisos en cada ejecución
- Usa el mismo tamaño de entrada (mismo payload, mismo número de registros)
- Usa el mismo entorno (local o staging, no mixto)
- Reinicia la app entre experimentos para empezar desde una línea base limpia
- Apaga tráfico no relacionado para que el ruido de fondo no oculte el patrón
Ahora define una métrica clara de éxito/fracaso. Una simple es: después del bucle, y forzando una recolección de basura en pruebas, el heap debería volver cerca de la línea base. Si sigue subiendo ejecución tras ejecución, tienes un repro fiable.
Ejemplo concreto: imagina que la memoria crece cuando los usuarios abren una pantalla de “actualizaciones en vivo”. Haz un bucle que conecte al WebSocket, espere 3 segundos y se desconecte, repítelo 200 veces. Si el heap crece con cada ciclo de conexión, has acotado la fuga a listeners, timers o cachés por conexión.
Aquí también es donde los apps prototipo suelen fallar. Si tu código fue generado por herramientas como Replit, v0 o Cursor y es difícil hacer la fuga reproducible, FixMyMess puede ejecutar una auditoría rápida para aislar la acción que dispara el crecimiento antes de que pierdas horas adivinando.
Instantáneas del heap: la herramienta que hace visibles las fugas
Una instantánea del heap es una foto de los objetos que tu proceso Node.js mantiene en memoria en un momento dado. Muestra cuántos objetos existen, qué tamaño tienen y qué los mantiene vivos. Cuando buscas una fuga en Node.js, suele ser la forma más rápida de dejar de adivinar.
Qué puede decirte: qué tipos de objetos siguen creciendo (arrays, Maps, strings, closures) y las rutas que mantienen esos objetos alcanzables. Qué no puede decirte: la línea exacta de código que creó un objeto, o si un pico puntual es “malo” por sí mismo. Aun así necesitas una prueba reproducible y algo de trabajo de detective.
Una idea clave en las instantáneas son los retenedores. Un objeto no se recolecta si algo todavía lo referencia. Los retenedores son la cadena de referencias que mantienen un objeto vivo, por ejemplo: un singleton global tiene un Map, el Map contiene payloads de request, y esos payloads incluyen strings grandes. La “fuga” normalmente no es el objeto que ves creciendo, sino el retenedor que debería haberse vaciado.
Planea tomar tres instantáneas para poder comparar qué crece con el tiempo:
- Línea base: justo después del arranque, antes de cualquier carga.
- Instantánea 2: después de una cantidad conocida de carga (por ejemplo, 200 requests).
- Instantánea 3: tras más de la misma carga (por ejemplo, 600 requests).
Si los mismos grupos de objetos aumentan de la instantánea 2 a la 3, tienes una señal fuerte. Si la memoria sube pero el recuento de objetos permanece plano, quizá estés viendo Buffers, add-ons nativos o cachés normales.
La privacidad importa. Las instantáneas del heap pueden incluir datos de usuarios, tokens, cookies, payloads de request e incluso secretos expuestos que los prototipos a veces registran o mantienen en memoria. Trata las instantáneas como datos de producción: guárdalas con cuidado, compártelas con prudencia y bórralas cuando termines.
Paso a paso: captura y compara instantáneas para encontrar qué crece
Cuando sospeches una fuga en Node.js, las instantáneas son la forma más rápida de dejar de adivinar. El truco es tomar instantáneas alrededor de una acción repetida para ver qué aumenta cada vez.
1) Captura tres instantáneas alrededor de la misma acción
Arranca tu app de forma que puedas tomar instantáneas del heap (por ejemplo, vía el debugger o inspector). Luego repite una acción de usuario que creas que dispara la fuga (una petición, una carga de página, una ejecución de job).
Usa este ritmo simple:
- Instantánea A: toma una línea base justo después de que la app esté “estable”
- Haz la misma acción N veces (empieza con 20–50)
- Instantánea B: toma una segunda instantánea inmediatamente después
- Haz la misma acción N veces otra vez
- Instantánea C: toma una tercera instantánea
Si B es mayor que A, y C es mayor que B por una cantidad similar, ese aumento constante por iteración es una señal fuerte de fuga.
2) Compara instantáneas y sigue la ruta de retención
Abre la vista de comparación entre A y B (luego B y C). Concéntrate en los tipos de objetos que aumentan, no en los picos puntuales.
Busca:
- Nombres de constructores que sigan subiendo (por ejemplo: Array, Map, Listener, Timeout, Buffer)
- Colecciones que crecen (entradas de Map, elementos de Set, objetos en caché)
- Objetos “desconectados” o que parecen “inaccesibles” pero siguen retenidos
- La ruta de retención (qué está manteniendo el objeto en memoria)
La ruta de retención es la parte clave. A menudo apunta a un singleton global, una caché a nivel de módulo, un event emitter o una lista de timers.
3) Anota lo que ves antes de cambiar código
Mientras inspeccionas, toma notas rápidas para no perder el hilo:
- Los 2–3 nombres de constructores que más crecen
- La raíz de retención (global, exportación de módulo, closure del manejador de request)
- Cualquier pista de archivo o módulo que muestre la instantánea
- Tasa aproximada de crecimiento (por ejemplo: +500 objetos por 50 requests)
Esa lista corta hace que la corrección sea enfocada. También es la misma evidencia que equipos como FixMyMess usan en una auditoría gratuita para señalar si la fuga son listeners, cachés o timers antes de tocar el código.
Listeners descontrolados: la fuga más común en prototipos
Un clásico en Node.js es simple: un listener se añade una y otra vez, pero nunca se remueve. La memoria crece despacio y luego, de repente, el proceso empieza a pausar, a hacer timeouts o a bloquearse.
Suele ocurrir cuando una función de “setup” se ejecuta en cada request, reconexión o job y hace algo como emitter.on(...) sin comprobar si ya se suscribió. Cada nuevo listener puede mantener datos extra vivos, especialmente cuando el handler cierra sobre objetos de la request, datos de usuario o Buffers grandes.
Lugares comunes donde aparece:
- Instancias de
EventEmitterusadas como buses globales - Conexiones WebSocket que se reconectan y se vuelven a suscribir
- Streams HTTP donde los handlers
datayerrorse amontonan - Clientes de base de datos que adjuntan listeners en cada query
- Eventos de
processcomouncaughtExceptionoSIGTERMregistrados repetidamente
Las instantáneas pueden revelar este patrón. Busca un número creciente de funciones bajo arrays de listeners (a menudo en un emitter) o muchas closures similares que referencian las mismas variables externas. Una pista fuerte es ver objetos retenidos que parecen datos de request/response colgando del contexto o closure de una función listener.
Ejemplo concreto: una ruta Express llama a subscribeToUpdates(userId) en cada request, y esa función añade ws.on('message', ...). Si nunca se da de baja cuando la petición termina (o cuando el usuario se desconecta), el WebSocket mantiene referencias a handlers antiguos y a sus datos capturados.
Las soluciones suelen ser poco glamurosas pero efectivas:
- Usa
oncepara eventos que deben dispararse una sola vez - Llama a
off/removeListenerdurante la limpieza (al desconectar, al terminar la request, al finalizar el job) - Evita suscripciones por request a emitters globales; enruta eventos a través de un objeto con ámbito
- Guarda la función handler para poder eliminar exactamente la misma referencia más tarde
- Añade medidas: registra
listenerCounty trata las advertencias como bugs reales
Si heredaste código generado por IA con estos patrones, FixMyMess suele empezar mapeando los ciclos de vida de los listeners y eliminando trampas de “suscribirse en cada llamada” antes que nada.
Cachés que solo crecen: Maps, memoización y singletons globales
Muchas “fugas de memoria” en prototipos no son bugs misteriosos. Son cachés que nunca liberan. En una búsqueda de fugas en Node.js, este es uno de los primeros sitios a inspeccionar porque a menudo parece “la app funciona bien” hasta que el tráfico o el tiempo hacen la caché enorme.
El patrón clásico es un Map o un objeto plano usado como lookup rápido, pero sin límite de tamaño ni expiración. Si la clave se construye a partir de la entrada del usuario (términos de búsqueda, URLs, headers, user IDs, prompts), el número de claves únicas puede crecer indefinidamente.
Qué buscar
Empieza por encontrar cualquier cosa que guarde datos entre requests: variables a nivel de módulo, singletons o archivos “helper” que exporten una caché.
Algunos culpables comunes:
- Un mapa de deduplicación de requests (
inFlightRequests.get(key)) que nunca borra en rutas de error - Memoización alrededor de funciones costosas, indexada por input crudo
- Un mapa global de “últimas respuestas” para debugging o analytics
- Cachés que guardan objetos de respuesta completos, filas de BD o Buffers
- Datos tipo sesión guardados en memoria en lugar de en un store real
Escenario pequeño que filtra rápido: cacheas resultados de GET /search?q=... con la cadena de consulta completa como clave. Una semana después tienes cientos de miles de queries únicas, y cada valor incluye un payload JSON grande. Las instantáneas del heap mostrarán a menudo un gran Map (u Object) reteniendo arrays, strings y objetos anidados.
Patrones de caché más seguros
Arreglarlo generalmente significa hacer que la caché se comporte como una caché, no como un archivo:
- Añade un tamaño máximo (evict LRU o entradas más antiguas)
- Añade expiración TTL y limpieza en un intervalo que pueda pararse
- Normaliza claves (lowercase, trim, ordenar params) para reducir explosiones de clave
- Guarda IDs o resúmenes pequeños, no objetos enteros o respuestas crudas
- Borra siempre entradas en fallos y en rutas de timeout
Si heredaste un prototipo generado por IA, estas cachés suelen estar dispersas por archivos de utilidades y singletons. FixMyMess suele encontrar 2–3 mapas crecientes en el mismo repositorio, cada uno reteniendo más de lo necesario.
Intervals y bucles en segundo plano que nunca paran
Los timers son una forma fácil de crear una fuga en Node.js, especialmente en apps que empezaron como prototipos. El error clásico es crear un setInterval() (o setTimeout() encadenado) dentro de un manejador de request, y nunca limpiarlo. Cada request añade otro bucle en segundo plano que mantiene referencias vivas.
Suele ocurrir con funciones “rápidas”: polling a una API externa, reintentos de jobs fallidos, comprobar una cola o refrescar una caché. Cuando ese código está dentro de una ruta o setup por usuario, el timer cierra sobre datos de la request (user id, token, payload) y esa closure permanece en memoria mientras exista el timer.
Ejemplo realista: una ruta Express como /start-sync crea un intervalo para consultar el progreso cada 2 segundos. Si el usuario recarga la página o la llama dos veces, ahora tienes dos intervals para el mismo usuario. Multiplica eso por tráfico real y la memoria sube sin parar.
Las instantáneas pueden darte pistas claras. A menudo verás recuentos crecientes de objetos relacionados con timers, además de closures retenidas que apuntan a objetos de request o sesión. Si la comparación de instantáneas muestra más objetos “listeners” y Timeout después de cada ejecución de prueba, la lista de timers está creciendo.
Patrones de corrección que funcionan:
- Crea timers programados una sola vez al arranque, no dentro de rutas
- Guarda los IDs de timer y siempre llama a
clearInterval()oclearTimeout()cuando el trabajo termina - Ata la vida del timer a la conexión: cancela al desconectar, al hacer logout o cuando un WebSocket se cierra
- Protege contra duplicados (por ejemplo, un intervalo por usuario o por workspace)
- Prefiere un worker único que saque jobs de una cola a tener un timer por request
Tras el cambio, vuelve a ejecutar la misma carga y toma nuevas instantáneas del heap. Si la corrección es real, los objetos relacionados con timers dejan de crecer y la memoria comienza a estabilizarse.
Si tu app fue generada por herramientas como Lovable, Bolt o Replit y los timers están esparcidos por rutas, FixMyMess puede hacer una auditoría rápida y señalar exactamente dónde se crean los bucles y por qué nunca se detienen.
Demuestra la corrección: vuelve a ejecutar la prueba y confirma que la memoria se estabiliza
Una corrección real cambia lo que la app mantiene en memoria. Una máscara sólo cambia lo que notas. Reiniciar el servidor, aumentar la memoria del contenedor o forzar GC puede mejorar temporalmente las gráficas, pero la misma fuga sigue ahí.
Trata el paso de prueba como un experimento de laboratorio. Usa exactamente los mismos pasos de reproducción, el mismo tamaño de datos y los mismos ajustes de runtime que usaste cuando detectaste la fuga por primera vez.
Captura un nuevo conjunto de instantáneas del heap: una en arranque limpio, otra después de que la fuga haya tenido tiempo de crecer y (si la prueba incluye enfriamiento) una después de que la carga termine. Luego compáralas con las instantáneas “antes”.
Has terminado cuando se cumplen dos cosas:
- Los tipos de objetos que antes crecían (por ejemplo, arrays de listeners, entradas de Map, respuestas en caché, closures de timers) dejan de aumentar entre instantáneas.
- Tras el fin de la carga, el heap sube y baja y luego se estabiliza cerca de un rango fijo en lugar de subir con cada ejecución.
Ejemplo simple: eliminaste un setInterval errante que se creaba por request. En la siguiente ejecución, la instantánea dos ya no debería mostrar miles de callbacks de interval idénticos reteniendo datos de request, y la instantánea tres no debería ser significativamente mayor que la dos.
Si la fuga parece “arreglada” pero el heap sigue subiendo, revisa que no hayas movido el crecimiento a otro sitio. Las máscaras comunes incluyen añadir una caché LRU pero no fijar su tamaño, o eliminar listeners en un camino pero no en rutas de error.
Para seguridad a largo plazo, añade una pequeña comprobación de regresión antes del release. Hazlo corto y aburrido, sólo lo suficiente para detectar la vuelta del mismo patrón:
- Ejecuta una pequeña carga en un build de staging por 2–5 minutos
- Registra el peak de RSS/heap y falla si crece por encima de un umbral sensato
- Opcionalmente guarda una instantánea del heap y compara recuentos de objetos retenidos
Si heredaste un prototipo generado por IA y la misma fuga reaparece tras parches rápidos, FixMyMess suele ejecutar este ciclo de volver a correr y comparar tras las reparaciones para verificar el cambio, no confiar en la esperanza.
Errores comunes que hacen perder horas
Una instantánea no es prueba. Una sola vista del heap puede mostrar muchos objetos, pero no te dice qué está creciendo. Necesitas al menos dos instantáneas tomadas en el mismo punto de tu prueba para comparar y ver qué constructores y retenedores siguen aumentando.
El ruido es otro matador de tiempo. Si atacas la app con patrones de tráfico mezclados (login, uploads, cron, páginas aleatorias) verás crecimiento difícil de explicar. Mantén un bucle reproducible que dispare la fuga sospechada y cambia solo una cosa a la vez.
También es fácil culpar al recolector de basura. El GC de Node.js puede parecer “perezoso” bajo carga, pero si la memoria sigue subiendo y nunca baja, algo sigue fuertemente referenciado. Los culpables habituales son Maps globales, arrays en módulos, closures que capturan objetos grandes y listeners añadidos en cada request sin ser eliminados. Cuando persigues una fuga en Node.js, céntrate en qué está sosteniendo referencias, no en ajustes del GC.
Otra trampa es arreglar el síntoma en lugar de la causa. Limpiar una caché “cuando se hace grande” puede ocultar el problema por una semana y luego vuelve en producción.
Qué hacer en su lugar
Apunta a correcciones que hagan imposible el crecimiento:
- Añade límites de tamaño y eviction (TTL o LRU) a cachés en memoria
- Asegura que los listeners se registren una vez o se eliminen en la limpieza
- Detén timers e intervals cuando un job termina o un socket se cierra
- Evita almacenar objetos de request, sesiones o respuestas grandes en globals
Ejemplo: un prototipo añade setInterval por sesión de usuario para “refrescar datos”, pero nunca lo limpia en logout. El heap parece aleatorio hasta que ejecutas un bucle de login/logout y comparas instantáneas. Entonces un único callback de timer retenido aparece como la raíz.
Si heredaste una app Node generada por IA y la misma fuga reaparece tras parches rápidos, FixMyMess suele empezar con una auditoría corta para identificar la ruta exacta de retención, luego aplica una limpieza real y límites para que se mantenga corregida.
Lista rápida y siguientes pasos
Una fuga en Node.js es fácil de discutir y difícil de probar. Usa esta lista rápida para centrar tu trabajo y asegurarte de poder demostrar que la fuga se ha ido, no sólo que "se ve mejor en tu máquina".
Lista rápida
- ¿Puedes reproducir el crecimiento de memoria en menos de 10 minutos con una prueba reproducible (mismos endpoints, mismos payloads, misma concurrencia)?
- ¿Tomaste al menos 3 instantáneas del heap (baseline, mid-run, cerca del fallo) y comparaste qué crece entre ellas?
- ¿Inspeccionaste específicamente a los sospechosos habituales: listeners de evento, cachés en memoria (Maps, arrays, memoización) y timers/intervals?
- Tras los cambios de código, ¿volviste a ejecutar la misma prueba y confirmaste que la memoria se estabiliza (y los ciclos de GC no siguen subiendo)?
- ¿Comprobaste también señales laterales que apuntan a la causa, como conteo de listeners, handles abiertos y recuentos de claves en Maps que no dejan de crecer?
Si falla algún punto, para y arregla el proceso primero. La mayor pérdida de tiempo viene de cambiar código antes de poder reproducir el problema de forma fiable, o de tomar una sola instantánea y adivinar.
Siguientes pasos
Una vez hayas probado la estabilidad, evita que el problema vuelva:
- Añade una sencilla prueba de soak a tu rutina de releases (10–20 minutos suelen bastar para captar regresiones)
- Pon guardarraíles alrededor de código propenso a crecer: limita cachés, elimina listeners en la limpieza y detén intervals cuando el trabajo termine
- Documenta la “propiedad” de bucles en segundo plano y singletons para que no se multipliquen a medida que la app evoluciona
Si tu app empezó como un prototipo generado por IA, las fugas suelen venir de estado global enmarañado, listeners duplicados o jobs en segundo plano añadidos durante experimentación y nunca eliminados. En esos casos, una auditoría focalizada más un refactor dirigido suele ser más rápido que parchear porciones sueltas. FixMyMess puede ejecutar una auditoría de código gratuita, señalar qué está creciendo y ayudar a convertir el prototipo en código listo para producción con correcciones verificadas.