Tema representativo de entrevista

Entrevista Frontend: ¿Cómo diagnosticas y solucionas fugas de memoria en JavaScript?

FrontendDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un panel de control de pedidos SPA en React crea un gráfico, se suscribe a mensajes y observa cambios en la ventana cada vez que se abre su ruta de detalle. Después de 20 recorridos de ida y vuelta, la página se vuelve notablemente lenta; tras un recolector de basura forzado, el heap de JS, los nodos del DOM y los conteos de listeners siguen aumentando en cada ronda. Explica cómo demostrarías una fuga de memoria, cómo localizarías su ruta de retención con Chrome DevTools, cómo repararías el ciclo de vida de los recursos y cómo diseñarías una prueba de aceptación reproducible.

Planteamiento y contexto aplicable

Un panel de control de pedidos SPA en React contiene un gráfico en tiempo real, una suscripción a eventos de pedidos y un comportamiento de diseño responsivo. Un usuario abre la ruta de detalle de un pedido, regresa a la lista y repite el recorrido 20 veces. La página luego se vuelve lenta. Una grabación de Chrome Performance muestra que después de la misma operación y de una oportunidad de recolección de basura al final de cada ronda, la marca de agua mínima del heap de JS sigue aumentando. Los conteos de nodos del DOM y de listeners tampoco logran regresar a sus rangos iniciales.

El componente relevante se simplifica a continuación:

tsx
useEffect(() => {
  const chart = createOrderChart(containerRef.current)

  window.addEventListener("resize", () => chart.resize())
  orderBus.on("order", (order) => chart.append(order))
  const timerId = window.setInterval(() => chart.refresh(), 5_000)

  return () => {
    window.clearInterval(timerId)
  }
}, [])

La entrevista solicita una cadena de evidencia revisable. El candidato debe separar la asignación ordinaria, el exceso de memoria (memory bloat), la recolección de basura frecuente y una fuga genuina; identificar qué retiene a un objeto que debió haber muerto desde una raíz de GC; reparar los ciclos de vida de listeners, suscripciones, temporizadores e instancias de terceros; y demostrar la solución con el mismo ciclo de acciones.

Un banco público de preguntas de frontend de 2025 pregunta explícitamente cómo identificar y solucionar fugas de memoria en JavaScript. Otra guía de entrevistas de JavaScript de 2025 utiliza una SPA que se ralentiza tras navegaciones repetidas y pregunta cómo DevTools encontraría la fuga. Un relato público reciente de entrevistas registra preguntas de seguimiento sobre recolección de basura, clausuras (closures), listeners y desmontaje de componentes. La intención de búsqueda es concreta: los candidatos necesitan un flujo de trabajo de diagnóstico ejecutable en el navegador en lugar de una lista que termina en “limpiar temporizadores”.

Qué evalúa el entrevistador

La primera habilidad consiste en establecer una regla de decisión válida. Un aumento temporal de objetos, un pico más alto o un número creciente de memoria de proceso no demuestran por sí mismos una fuga. Una respuesta sólida fija el navegador, la compilación, los datos y la secuencia de acciones; le da a la recolección una oportunidad comparable entre rondas; y compara varias líneas base activas. La asignación ordinaria puede formar un patrón en diente de sierra que sube y baja. Los objetos que sobreviven y se acumulan tras la misma operación justifican una investigación de sus rutas de referencia.

La segunda habilidad es la alcanzabilidad. Los recolectores de basura modernos de JavaScript marcan los objetos alcanzables desde raíces como el objeto global. Que la aplicación ya no desee un objeto no hace que ese hecho sea visible para el recolector. Si un listener de window, un bus de eventos, un temporizador, una caché o una biblioteca de terceros todavía mantienen una referencia fuerte, el objeto permanece alcanzable. Un ciclo por sí solo no necesariamente genera una fuga: un grupo cíclico completo puede recolectarse una vez que ninguna ruta desde una raíz lo alcance.

La tercera habilidad consiste en interpretar la evidencia en lugar de simplemente abrir el panel Memory:

EvidenciaPregunta que respondeLo que no puede demostrar por sí sola
Administrador de tareas / Curva de memoria en Performance¿El trabajo repetido sigue aumentando la memoria?Qué objeto la retiene
Comparación de instantáneas del Heap (Heap Snapshot Comparison)¿Qué tipos activos y conteos de referencias aumentaron?Si el crecimiento cumple con un contrato de caché
Retenedores / ruta de retención (Retainers / retaining path)¿Qué cadena de referencias conecta un objeto a una raíz?Cuándo el código propietario debería liberarlo
Instrumentación de asignaciones en la línea de tiempo (Allocation instrumentation on timeline)¿Qué acción asignó objetos que luego sobrevivieron?Que toda asignación sea una fuga
Conteos de nodos del DOM, documentos y listeners¿El crecimiento está orientado al DOM o a los listeners?La memoria total del proceso fuera del heap de JS

La habilidad final es la propiedad de los recursos. El límite del ciclo de vida que crea un recurso debe liberarlo. AbortController puede eliminar los listeners del DOM registrados con su signal, pero no desconecta un ResizeObserver, no cierra un WebSocket, no cancela la suscripción a un bus de eventos ni destruye un gráfico de terceros. Esos recursos aún necesitan su propia operación de disconnect, close, unsubscribe o destroy.

Preguntas para aclarar antes de responder

  • ¿Qué reproduce exactamente el problema? Fija el punto de entrada, la acción de retorno, la condición de preparación por ronda, el volumen de datos y el conteo de repeticiones. Diferentes pedidos en cada ronda contaminan las diferencias de instantáneas con diferencias de datos de negocio.
  • ¿Qué medición crece? La huella de memoria del SO, el heap de JS, los nodos del DOM, los documentos y los listeners cubren regiones diferentes. Delimita el problema antes de seleccionar el análisis de instantáneas o de asignación.
  • ¿El producto almacena algo en caché intencionalmente? Una caché de rutas delimitada o un historial de gráficos pueden permanecer activos por diseño. La capacidad, el desalojo y las expectativas de estado estable distinguen el uso controlado de una fuga.
  • ¿El componente realmente se desmonta? El enrutamiento puede ocultarlo, preservarlo o reutilizarlo. Confirma el ciclo de vida real con registros de montaje y limpieza antes de reparar un desmontaje que nunca ocurre.
  • ¿Qué objetos provienen de bibliotecas de terceros? Los gráficos, editores y mapas a menudo son propietarios de DOM, workers, observadores y listeners internos. Limpiar el HTML del contenedor no destruye la instancia.
  • ¿Se puede reproducir el síntoma de producción en un entorno de pruebas? Las instantáneas del heap pueden contener datos de usuario. Prefiere una reproducción controlada con datos saneados y aplica controles de privacidad y acceso.
  • ¿Qué define el éxito? No existe un límite universal en MB independiente del dispositivo y la carga de trabajo. Acuerda la pendiente de la línea base posterior a la operación, el conteo de instancias activas, los conteos de nodos/listeners y los bloqueos visibles para el usuario.

Marco de respuesta de 30 segundos

“Fijaría una acción lista-detalle-lista, realizaría un calentamiento previo, la repetiría y compararía líneas base activas en puntos de control de GC idénticos. Si los puntos bajos del heap, los nodos del DOM o los listeners se acumulan, comparo instantáneas, encuentro los tipos que crecen y sigo los Retainers hasta window, un bus de eventos o un temporizador. Añado Allocation timeline si el origen no queda claro. Luego, el creador libera los listeners, suscripciones, temporizadores, observadores y gráficos. Vuelvo a ejecutar el mismo ciclo y exijo que los conteos de instancias bajen, las líneas base se estabilicen y el comportamiento siga siendo correcto.”

Análisis detallado paso a paso

Paso 1: Demostrar la fuga con un experimento repetido

Comienza con una línea base comparable. Utiliza la misma versión de Chrome con las extensiones no relacionadas minimizadas, y fija la ventana, la cuenta de prueba, los datos del pedido y la ruta. Tras recargar, completa un calentamiento previo para que se inicialicen los módulos diferidos (lazy), fuentes, grupos de conexiones y cachés únicas. En el panel Performance, habilita Memory y ejecuta:

  1. Dispara la recolección de basura una vez y registra el punto inicial.
  2. Completa cinco recorridos lista-detalle-lista, esperando la misma condición de “gráfico renderizado” cada vez.
  3. Dispara la recolección de basura nuevamente y registra los puntos mínimos de heap de JS, nodos del DOM, documentos y listeners.
  4. Repite tres grupos y compara los puntos mínimos de los grupos en lugar de picos arbitrarios.

El momento de la recolección pertenece al runtime. Un experimento corto también se ve afectado por el trabajo de JIT, la decodificación de imágenes, las respuestas de red y el propio DevTools, por lo que una diferencia en una sola ronda crea solo una hipótesis. Un primer grupo más alto que luego se estabiliza puede ser un calentamiento. Si cada grupo retiene un nuevo OrderChart, un conjunto de nodos desvinculados y dos listeners en proporción al conteo de acciones, la evidencia es más sólida.

Mantén tres fenómenos separados:

  • Fuga (Leak): las líneas base activas posteriores al GC siguen aumentando después de la misma operación.
  • Exceso de memoria (Memory bloat): el uso en estado estable es excesivo pero ya no crece sin límite con el conteo de operaciones.
  • Rotación de asignaciones (Allocation churn): el heap sube y baja con pausas frecuentes de GC, y los puntos mínimos se recuperan; el problema es un exceso de asignación temporal.

La huella de Memory del Administrador de tareas incluye el uso del proceso, como el almacenamiento del DOM. El valor activo en JavaScript Memory es más cercano al heap de JS alcanzable. Estas mediciones clasifican la investigación, pero no reemplazan la evidencia de referencias de una instantánea del heap. El GC forzado es un control de diagnóstico, no una solución de producto, y el código de producción no puede prometer la recolección en un momento particular.

Paso 2: Localizar al propietario responsable con instantáneas y rutas de retención

Tras establecer el crecimiento, toma la Instantánea A en el panel Memory. Realiza el ciclo de navegación fijado, regresa a la lista, espera la limpieza asíncrona y toma la Instantánea B. La captura de instantáneas comienza con la recolección de basura, por lo que la vista Comparison es útil para objetos que siguen siendo alcanzables. Ordena por delta de instancias y tamaño retenido (retained size), buscando constructores de negocio, clausuras, arreglos y árboles DOM desvinculados que crezcan con el conteo de ciclos.

shallow size describe el objeto en sí mismo. retained size estima la memoria que podría liberarse si ese objeto dejara de ser alcanzable. Un callback de listener pequeño puede retener un gráfico, un arreglo de datos y un subárbol DOM completo a través de su clausura, por lo que el tamaño retenido es una mejor señal de priorización que el tamaño del callback. Es una estimación de investigación, no una afirmación directa de memoria física exclusiva.

Selecciona un OrderChart o nodo desvinculado que debería haber muerto e inspecciona Retainers. Una ruta puede verse como:

text
Window
└─ resize event listener
   └─ callback closure
      └─ chart
         └─ container
            └─ detached HTMLDivElement

Esta ruta explica por qué el GC no puede recolectar el grafo: el window global todavía posee el callback anónimo de redimensionamiento, cuya clausura posee el gráfico. Eliminar el DOM del documento cambia su relación con el documento; no rompe la ruta de JavaScript desde la raíz. La reparación corresponde a la propiedad del listener y del gráfico, no a una asignación especulativa de null al nodo desvinculado.

Si las instantáneas solo muestran entradas genéricas de Object y Array, utiliza Allocation instrumentation on timeline. Inicia la grabación, realiza exactamente una acción con fuga, detén y enfócate en las asignaciones que permanecen activas a través de la recolección. Sigue su constructor y pila de asignación de regreso al código. El muestreo de asignaciones tiene menor sobrecarga y es útil para encontrar funciones con alta intensidad de asignación, pero su resultado muestreado no reemplaza a una instantánea cuando las instancias individuales y los retenedores son lo que importa.

Las fuentes comunes de retención incluyen:

  • objetos globales o arreglos de módulo que agregan instancias de página indefinidamente;
  • listeners de DOM/EventTarget que nunca se eliminaron, o se eliminaron con una identidad de función o una opción de captura diferente;
  • callbacks de setInterval y tiempos de espera recursivos que retienen el estado del componente;
  • buses de eventos, stores, WebSockets u observables sin cancelación de suscripción;
  • ResizeObserver, IntersectionObserver, workers e instancias de terceros sin destrucción;
  • Maps, colecciones de historial o cachés de peticiones sin un límite de capacidad;
  • promesas que nunca se resuelven o trabajo en cola que retiene clausuras por un tiempo ilimitado.

Paso 3: Reparar la propiedad de recursos y volver a ejecutar la misma prueba de aceptación

El Effect reparado conserva un manejador de liberación para cada recurso. AbortController puede ser propietario de los listeners del DOM, mientras que los recursos restantes se liberan explícitamente:

tsx
useEffect(() => {
  const container = containerRef.current
  if (!container) return

  const controller = new AbortController()
  const chart = createOrderChart(container)
  const observer = new ResizeObserver(() => chart.resize())
  const unsubscribe = orderBus.on("order", (order) => chart.append(order))
  const timerId = window.setInterval(() => chart.refresh(), 5_000)

  observer.observe(container)
  window.addEventListener("visibilitychange", () => chart.syncVisibility(), {
    signal: controller.signal,
  })

  return () => {
    controller.abort()
    observer.disconnect()
    unsubscribe()
    window.clearInterval(timerId)
    chart.destroy()
  }
}, [])

Verifica el contrato de API real. Algunos métodos de on devuelven una función para cancelar la suscripción; otros requieren que se proporcione el mismo manejador a off. Si el cambio de dependencias puede recrear un recurso, la limpieza debe liberar solo la instancia creada por esa ejecución del Effect y no debe cerrar a su sucesor. Las peticiones asíncronas también necesitan cancelación o una verificación de generación. Evitar una escritura de estado tras el desmontaje aborda un efecto; la fuga persiste si una suscripción externa aún posee la clausura.

WeakMap es adecuado para metadatos con clave de objeto, pero no reemplaza la limpieza explícita del ciclo de vida. WeakRef ofrece deliberadamente pocas garantías sobre el momento de la recolección y el comportamiento observable, y suele ser una mala solución para listeners, conexiones o instancias de gráficos. La reparación directa consiste en romper referencias fuertes innecesarias y dotar a las cachés intencionales de una capacidad, TTL o condición de desalojo.

La aceptación vuelve a ejecutar el experimento exacto previo a la corrección: la misma compilación, datos, conteo de navegaciones, puntos de preparación y control de GC. Las condiciones de aprobación incluyen:

  • los componentes de detalle activos, las instancias de gráficos y los subárboles desvinculados regresan al conteo acordado tras salir;
  • a través de varios grupos, las líneas base posteriores al GC fluctúan dentro de un rango estable en lugar de seguir el conteo de navegaciones de forma aproximadamente lineal;
  • los conteos de listeners, documentos y nodos del DOM regresan a sus rangos esperados;
  • cada montaje posee una suscripción a mensajes y una instancia de gráfico, y cada desmontaje libera cada uno una sola vez;
  • los gráficos aún se actualizan, el comportamiento de redimensionamiento del contenedor y de visibilidad funciona, y volver a visitar recrea los recursos;
  • los bloqueos prolongados, las pausas de GC y las señales de fallos cumplen con el presupuesto del producto.

Conserva las instantáneas de antes y después, los pasos de reproducción, la identidad de la compilación y la ruta de retención clave para revisión. Una instantánea puede contener cadenas y objetos de negocio, por lo que debe almacenarse y compartirse como material sensible de depuración.

Respuesta de muestra de alta calidad

“La curva es una evidencia sólida, pero un solo incremento no es suficiente para mi conclusión. Fijaría los datos del pedido, definiría lista-detalle-lista como una sola operación, haría un calentamiento previo y luego ejecutaría tres grupos de cinco. En estados de página idénticos antes y después de cada grupo, solicitaría GC y registraría los puntos mínimos de heap, nodos del DOM, documentos y listeners. Si solo el primer grupo aumenta y los grupos posteriores se estabilizan, inspecciono la inicialización o una caché delimitada. Si cada grupo agrega el mismo número de gráficos y listeners, procedo a las instantáneas.”

“Tomo la Instantánea A después del calentamiento, ejecuto el ciclo, regreso a la lista, espero la limpieza y tomo la Instantánea B. En Comparison comienzo con las diferencias de instancias y tamaño retenido. Supongamos que encuentro 15 instancias de OrderChart que deberían estar muertas, con Window → resize listener → closure → chart → detached container en Retainers. Esa ruta explica la falla: window todavía posee el listener anónimo, cuya clausura retiene el gráfico y el DOM. Si los nombres de los constructores son genéricos, grabo un Allocation timeline y uso su pila de asignación para mapear los objetos sobrevivientes de esa navegación de regreso al código.”

“Para la reparación, el Effect conserva cada manejador de liberación: los listeners del DOM usan un AbortController, el bus de eventos cancela la suscripción, el temporizador se limpia, el observador se desconecta y el gráfico se destruye. Si una biblioteca requiere off(handler), preservo la misma identidad del manejador. Las cachés obtienen capacidad o desalojo. Vuelvo a ejecutar el experimento idéntico y apruebo únicamente cuando los gráficos y los subárboles desvinculados regresan al conteo acordado, las líneas base posteriores al GC dejan de seguir el conteo de navegaciones y volver a visitar aún suscribe y renderiza.”

Errores comunes

  • Declarar una fuga a partir de un solo pico → La asignación ordinaria, el trabajo de JIT y las cachés también elevan los picos → Compara múltiples líneas base posteriores al GC y correlaciona el conteo de instancias con el conteo de operaciones.
  • Limpiar un contenedor tras ver DOM desvinculado → Un listener global o una clausura aún pueden retenerlo → Sigue los Retainers hasta una raíz y libera al propietario real.
  • Reemplazar cada estructura con WeakMap o WeakRef → Otras referencias fuertes aún hacen que los objetos sean alcanzables → Elimina primero los listeners, suscripciones, temporizadores y referencias de caché no deseados.
  • Solo evitar setState después del desmontaje → Los recursos externos aún pueden retener el callback y el grafo de objetos → Cancela el trabajo y anula su registro.
  • Verificar únicamente que la página siga respondiendo a clics → La corrección funcional no demuestra la liberación → Repite el experimento idéntico de instantáneas y compara instancias activas, líneas base y rutas de retención.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Por qué una referencia circular no necesariamente genera una fuga?

El algoritmo de marcado y barrido (mark-and-sweep) consulta si una raíz puede alcanzar los objetos. Dos objetos pueden referenciarse mutuamente y aun así ser recolectados cuando ninguna ruta externa alcanzable apunta al grupo. Si un listener de window, una caché de módulo o un temporizador activo alcanza a uno de ellos, el grupo entero permanece alcanzable.

Pregunta de seguimiento 2: ¿Por qué un nodo del DOM eliminado puede permanecer en la instantánea?

La eliminación desvincula el nodo del árbol del documento. Una variable de JavaScript, un callback de listener, un componente de terceros o un observador aún pueden referenciarlo. Inspecciona los Retainers del nodo desvinculado, localiza al propietario activo a lo largo de la ruta e invoca la operación de liberación de dicho propietario.

Pregunta de seguimiento 3: ¿Cuándo eliges Heap Snapshot, Allocation timeline o muestreo?

Las instantáneas comparan objetos activos en puntos estables y exponen rutas de retención por instancia. Allocation timeline conecta una acción del usuario con asignaciones que permanecen activas después. El muestreo encuentra funciones responsables de asignaciones pesadas con menor sobrecarga. Una secuencia práctica demuestra la curva primero, usa instantáneas para objetos y referencias, y luego agrega la grabación de asignaciones cuando el origen no queda claro.

Pregunta de seguimiento 4: ¿Puede “la memoria debe crecer menos de 10 MB” ser un control automatizado?

Un valor absoluto fijo es sensible al dispositivo, navegador, compilación, datos y tiempos de GC. Un control más robusto fija el entorno y la acción, realiza un calentamiento previo, repite grupos y verifica la pendiente de la línea base posterior al GC, el conteo activo de constructores controlados y los conteos de DOM/listeners. Establece umbrales a partir de la distribución histórica de esa página y del presupuesto del producto, con una tolerancia explícita al ruido.

Pregunta de seguimiento 5: ¿Qué sucede si la página se oculta pero nunca se desmonta?

Aclara el contrato del producto. Para un comportamiento intencional de mantenimiento activo (keep-alive), pausa temporizadores, reduce el muestreo o detén suscripciones invisibles y reanúdalas más tarde; sigue delimitando las cachés y el conteo de instancias. “Cero después de regresar a la lista” ya no aplica. La aceptación debe exigir un uso estable y acotado, y una liberación total cuando el componente se destruya efectivamente.

Fuentes públicas

Preguntas relacionadas