EponwebPractical guides to web development and technology
Frontend Engineering

The Phantom Listener: How We Slashed Dashboard RAM by 40% in One Go

Tracing a critical memory leak in a high-frequency dashboard using Chrome Heap Snapshots to find a single forgotten window listener.

Lucas Ferreira
Lucas FerreiraLead Frontend Engineer6 min read
Editorial image illustrating The Phantom Listener: How We Slashed Dashboard RAM by 40% in One Go

It was 10:30 AM on a Tuesday in February 2026 when the alarms for "Project Aries" went off. Aries is our internal real-time analytics dashboard, handling about 4,000 WebSocket events per minute to visualize server health for our clients. It is built on React 19, runs on a strict Vite build pipeline, and usually sits comfortably at around 180 MB of committed memory.

That morning, however, the Node.js monitoring bot reported that the frontend tab on a staging machine had crossed the 1.2 GB threshold. The user experience was degrading fast—charts were lagging behind the data stream by seconds, and the scrolling frame rate dropped to a jerky 15 FPS. We weren't dealing with a server crash; the backend was humming along perfectly. This was a classic, insidious client-side memory leak, and it was happening in a part of the codebase we hadn't touched in six months.

Why the WebSocket Wasn't the Villain

My first instinct, and the instinct of the two junior devs working with me, was to look at the data ingestion layer. When you have a real-time dashboard that leaks memory, you usually assume you are hoarding data in a state array that never clears. We spent the first three hours combing through our Redux slices and Context providers.

We checked for unshift operations on arrays that grew indefinitely. We verified that our pagination logic was discarding old DOM nodes properly. We even profiled the JSON parsing cost of the incoming WebSocket messages. The data stream was constant and clean. The state management was solid. If we stopped the data flow, the memory usage plateaued, but it never went down. This meant the problem wasn't that we were holding too much data; it was that the garbage collector (GC) was unable to free memory that should have been trash.

We were chasing a ghost in the state machine while the real issue was likely lurking in the DOM's event system.

Isolating the Leak with Heap Snapshots

Since Chrome's Performance profiler gave us too much noise to parse effectively, I switched tactics. I opened the DevTools Memory tab and took a "Heap Snapshot" immediately after the page loaded. I then let the dashboard run for five minutes—simulating a user switching tabs and resizing the window—before taking a second snapshot.

The comparison view is where the story unfolds. You select the second snapshot and switch the view from "Summary" to "Comparison" against the first snapshot. This filters out everything that was there from the start and highlights only what was created.

The results were damning. We had thousands of "Detached DOM tree" nodes. These are HTML elements that have been removed from the document but are still kept alive in memory because JavaScript code still holds a reference to them.

Photographic detail related to The Phantom Listener: How We Slashed Dashboard RAM by 40% in One Go

Clicking on one of these detached trees revealed the "Retainers" section at the bottom. A retainer is the object holding the reference preventing the GC from doing its job. In almost every case, the retaining path led back to a window object, specifically a resize event listener.

The window.resize Listener That Never Died

Tracing the code back, we found the culprit in a legacy component called HeatmapGrid. This component rendered a canvas and needed to resize itself whenever the browser window changed dimensions.

The code looked something like this:

useEffect(() => {
  const handleResize = () => {
    // Resize logic
  };
  window.addEventListener('resize', handleResize);
}, []); // Missing dependency array or cleanup

The developer had intended to listen for resize events. However, when the user navigated away from the dashboard view (using our new Smooth SPA Navigation: Implementing the View Transitions API without Polyfills), the HeatmapGrid component unmounted. The React node was destroyed, but the handleResize function—defined inside the effect—remained registered to the global window object.

Because window lives for the lifetime of the tab, and window held a reference to handleResize, and handleResize held a closure reference to the component's scope, the entire component tree and all its associated data were kept in memory forever. Every time the user navigated back to the dashboard, a new component mounted, a new listener was attached, and the old one stayed there, festering.

Refactoring for Reliability and Performance

The fix was conceptually simple but required a strict change to our team's linting rules. We needed to ensure that every addEventListener has a corresponding removeEventListener.

Here is the corrected implementation we deployed:

useEffect(() => {
  const handleResize = () => {
    // Resize logic
  };

  window.addEventListener('resize', handleResize);

  // The cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

However, in 2026, we have better tools for managing global events than manual cleanup. We refactored the component to use the modern AbortController pattern, which is now supported in all evergreen browsers (Chrome 66+, Firefox 57+, Safari 12.1+, and Edge 79+).

useEffect(() => {
  const controller = new AbortController();

  const handleResize = () => {
    // Resize logic
  };

  window.addEventListener('resize', handleResize, { signal: controller.signal });

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

Using AbortController centralizes the teardown logic. If we decide to add a scroll listener or a keydown listener to the same component later, we just pass the same signal property. One abort() call kills them all. This significantly reduces the human error factor of mismatching function references during removal.

Accessibility and Responsiveness Gains

The drop in memory usage was immediate and drastic. We went from a 40% increase in memory usage every five minutes to a flat line that stayed at 180 MB indefinitely. But the benefits went beyond just raw bytes.

High memory usage triggers aggressive Garbage Collection cycles. When the browser realizes it is running out of RAM, it pauses the main thread to clean up. These "stop-the-world" pauses are what caused the stuttering frame rates. By stabilizing memory, we eliminated these jank-inducing GC pauses.

This had a direct, positive impact on accessibility. Screen readers and keyboard navigation rely on a consistent, non-blocking main thread to interpret user input instantly. The erratic GC pauses we were experiencing previously would interrupt screen reader announcements or delay keyboard focus changes. By removing the leak, we ensured that the interface remained responsive to assistive technologies, adhering to WCAG 2.2's guidelines on responsive input timing.

We often discuss semantic structure—like using the 5 Semantic HTML Tags You Should Use Instead of Divs (And Why Screen Readers Prefer Them)—but runtime performance is equally vital for an accessible experience. If the UI freezes, the semantics don't matter.

Conclusion: The Cost of Implicit Globals

The scariest part of this bug is that it wasn't a complex algorithmic failure. It was a forgotten listener on a global object. In component-based architectures like React, Vue, or Svelte, it is easy to forget that the DOM lifecycle and the JavaScript event lifecycle are not perfectly coupled by default.

We walked away from this incident with a new internal ESLint rule that forbids window.addEventListener (and document.addEventListener) without a corresponding cleanup mechanism in the same scope. Memory leaks are often silent killers of web performance, but tools like Heap Snapshots make them visible if you know where to look. The fix didn't require a rewrite of the architecture or moving to React Server Components vs Client Components: When to Choose Which for Your E-commerce Cart; it just required respecting the lifecycle of the browser environment we are running in. Check your listeners, because the window never forgets, even if your React components do.

Read next