JavaScriptNov 3, 2026 · 8 min read

investigating, debugging and solving memory leaks in javascript and react

The first time I saw it, I did not know what I was looking at. I had DevTools open on an app I was building, and the memory graph kept climbing like a staircase. Up, flat, up, flat, never really coming back down. The app felt fine for the first five minutes. After twenty, everything was sluggish. That staircase is the universal sign. Something in my code was holding onto memory it should have let go of.

What a memory leak actually is

JavaScript cleans up after you. When nothing references an object anymore, the garbage collector frees it. A memory leak is when something you forgot about keeps a reference alive, so the collector can never do its job. It is not dramatic. There is no error, no crash, at least not at first. Just a slow climb until the tab gets heavy and your users start complaining that the app "feels slow lately".

The usual suspects

In every leak I have ever tracked down, the culprit was one of these. No exotic stuff, just boring mistakes:

  • Event listeners with no cleanup. You add one in a useEffect, the component unmounts, the listener stays. Mount it ten times, you have ten listeners.
  • Intervals and timeouts never cleared. Same story. A setInterval that outlives its component keeps the whole closure alive, and the closure keeps everything it references alive.
  • Fetch resolving after unmount. The component is gone, the response arrives anyway, and setState fires into the void. React 18 removed the warning for this, but the wasted work is still real.
  • Detached DOM nodes. You remove an element from the page, but some variable still points at it. The browser cannot free what you are still holding.
  • Unbounded caches. A Map or an array that only ever grows. Every search result cached forever. Every socket message appended to state with no cap.
  • Third-party instances never torn down. Chart instances, socket connections, IntersectionObservers and ResizeObservers. If the library gives you a destroy or disconnect method, it is there for a reason.

Finding it with Chrome DevTools

Start with the 30-second smoke test. Open DevTools, go to the three-dot menu, More tools, Performance monitor. Watch the "JS heap size" number while you use the app. Click around, open and close things. If the number climbs and never comes back down, you have a leak. Congratulations, now you get to do the fun part.

The real tool is the Memory tab. Here is the process that has never failed me:

  • Open the Memory tab and take a heap snapshot. This is your baseline.
  • Do the suspicious action a few times. Open and close that modal five times. Navigate to the page and back three times.
  • Take another snapshot. Change the view at the top from "Summary" to "Comparison".
  • Look for constructor counts that grew between snapshots. If you opened and closed a modal five times and there are five extra of something, you found it.
  • Type "detached" in the filter box. Detached DOM nodes show up here, and each one is a thing the page removed but your code is still holding.

If snapshots feel too static, try allocation sampling instead. Hit record, interact with the app, stop. It shows you which functions allocated the memory that survived. It points at the code, not just the objects, which is usually what you actually want.

The React profiler angle

Open React DevTools and go to the Profiler tab. Record a session while you interact. You are looking for components that mount and unmount in a loop, or re-render far more than they should. A component that mounts 50 times when you expected 5 is either the leak or standing next to it.

And here is the freebie most people ignore. If you are using StrictMode in development, React runs your effects twice: setup, cleanup, setup. If your cleanup function is missing or wrong, you see the symptom immediately. Double event listeners, double intervals, double subscriptions. StrictMode is a leak detector wearing a trench coat. Let it do its job and do not disable it because the double-invoking annoys you. (It annoyed me too. It was right.)

The fixes

The fix is almost always the same shape: if you started something, stop it in the cleanup. Here is the classic, the event listener with no cleanup:

leaky-listener.tsxTypeScript
useEffect(() => {
  const onResize = () => {
    setWidth(window.innerWidth)
  }
  window.addEventListener('resize', onResize)
  // nothing removes this. Ever.
}, [])

The fix is three lines:

fixed-listener.tsxTypeScript
useEffect(() => {
  const onResize = () => {
    setWidth(window.innerWidth)
  }
  window.addEventListener('resize', onResize)
  return () => {
    window.removeEventListener('resize', onResize)
  }
}, [])

Same idea for intervals:

fixed-interval.tsxTypeScript
useEffect(() => {
  const id = setInterval(() => {
    setTick((t) => t + 1)
  }, 1000)
  return () => clearInterval(id)
}, [])

And for fetch, the clean fix is an AbortController. It cancels the request when the component unmounts, so there is no late response and no setState into the void:

fetch-with-abort.tsxTypeScript
useEffect(() => {
  const controller = new AbortController()

  fetch('/api/users', { signal: controller.signal })
    .then((res) => res.json())
    .then(setUsers)
    .catch((err) => {
      if (err.name !== 'AbortError') throw err
    })

  return () => controller.abort()
}, [])

So, what have I learnt?

  • Every subscription needs an unsubscription. If you call something that starts, find the thing that stops it, and put it in the cleanup.
  • The Comparison view in heap snapshots is the whole game. Learn it once, use it forever.
  • StrictMode is trying to help you. Let it.
  • Leaks are rarely exotic. It is almost always a forgotten listener, an interval, or a cache with no cap.

Go open the Memory tab on your own app right now and watch the heap while you click around. Either you will feel very good about your code, or you will find your next 30 minutes of work.