Memory leak in React JS application

Memory leak in React JS application

Memory leaks or unintentional memory growth in a React application can be risky, potentially causing the browser tab—or even the entire browser—to crash.

Recently, I encountered this issue firsthand. We were working on a React application that displayed numerous charts across multiple pages. On each page, there were typically 5-7 charts, all powered by Apache ECharts.

The Problem

Consider the example chart below. We had six such charts on a single page.

Example Chart

Each chart had customizable options, allowing users to modify the x-axis length, y-axis scale, and other parameters.

The application would load once and then remain idle until refreshed. An API call was made every minute to update the chart data automatically. With six charts, this meant that each chart could make one or two API calls per minute, and the page could stay idle for 7-30 days.

The component structure looked like this:

SitePage
├── ReactGridLayout
│   ├── Components loop
│   │   ├── SiteChartContainer
│   │   ├── API call
│   │   │   ├── DataChart
│   │   │   └── SingleValue
│   │   │   └── ChartSettings

Within the DataChart, we had additional child components like SingleValue and ChartOptions.

The issue arose because every time a new API call was made, the SingleValue component was re-rendered. We monitored the memory usage and noticed that it increased by approximately 1MB every 10 minutes.

Memory Usage

After one or two days—or sometimes after 7+ days—the memory usage would increase by ~1GB, eventually causing the tab (and sometimes the entire browser) to crash.

The Solutions

When the browser crashed, we began a thorough investigation to identify the root cause of the problem. It became evident that the application's memory usage was gradually increasing to an unsustainable level.

Identifying the Issue

In our React application, we had numerous event listeners, such as click and mouse event listeners, as well as some timer functions that weren't being cleared when a component unmounted. As a result, the memory consumed by these listeners, timers, and hooks wasn't being released, and the JavaScript garbage collector wasn't clearing them either.

Additionally, we discovered issues with the chart's tooltip formatting, which relied on a closure function. This caused the entire series to depend on certain calculations.

After identifying these issues, we were able to take action by reviewing every component related to this page and making the necessary updates.

Optimizing the Component Hierarchy

As you can see from our initial component hierarchy, we decided to refactor it—particularly the SiteChartContainer component.

Initially, we used the React Context API to pass data to child components. However, we found that the Context API wasn't ideal in this case, as it caused the entire component tree to re-render whenever the context value changed. We decided to switch to Zustand for state management and used React's memo to optimize child component rendering.

01 - The New Component Hierarchy

SitePage
├── ReactGridLayout
│   ├── Components loop
│   │   ├── SiteChartContainer
│   │   ├── ├── DataFetcher
│   │   │   ├── DataChartDom
│   │   │   └── DataChartOptions
│   │   │   └── SingleValue
│   │   │   └── ChartSettings

We restructured the SiteChartContainer component, separating it into sibling components instead of nested components. Data is now passed to child components through the Zustand store, ensuring that only the relevant component re-renders when the data changes.

Key Components:

  • SiteChartContainer: Contains all initialization and sibling components.

  • DataFetcher: Fetches data from the API and stores it in Zustand.

  • DataChartDom: Renders the eChart using React DOM references.

  • DataChartOptions: Sets initial eChart options and updates them when the data changes.

  • SingleValue: Renders the latest chart value from the Zustand store.

  • ChartSettings: Manages chart settings and updates options accordingly.

02 - Adding Clean-Up Functionality: Timers & Listeners

We also refactored event listeners and timers, using the useEffect hook to add these functionalities and ensure they were cleared when the component unmounted.

JavaScript provides built-in functions like setTimeout for executing code asynchronously after a specific duration, setInterval for regular intervals, and addEventListener for DOM event manipulation. However, if not properly cleared, these can cause memory issues.

Here's a simple example of a listener with clean-up functionality:

useEffect(() => {
  const listener = () => {
    // handle click event
  };
  window.addEventListener('click', listener);
  return () => {
    window.removeEventListener('click', listener);
  };
}, []);

If you don't remove these listeners during component unmount, they will persist in memory, causing problems with each re-render.

Another example of clearing timer functions:

useEffect(() => {
  const timer = setInterval(() => {
    // perform some action
  }, 1000);
  return () => {
    clearInterval(timer);
  };
}, []);

Always ensure that you remove or clear timers and event listeners to avoid memory issues.

03 - Refactoring the Chart Tooltip

This was a tricky part. We discovered that the chart tooltip wasn't releasing memory, which turned out to be an internal issue with eCharts (reference issue). To mitigate this, we passed the necessary data to the series value for the tooltip during the data calculation in the DataChartOptions component and used a format function to handle the tooltip formatting.

type ChartSeriesData = [
    number, // timestamp
    number, // value
    string, // unit
    string|object, // necessary data for tooltip
];

By decoupling the tooltip from the series data, the memory was successfully released.

The Result

After optimization, we observed that memory usage no longer increased significantly, and the application has been running smoothly for over 20+ days.

Improved Memory Usage

We also refactored the Material-UI theme switcher functionality. Previously, switching between light and dark themes caused the entire page to re-render.

JavaScript Garbage Collection and Manual Clean-Up

JavaScript employs a form of automatic memory management known as garbage collection. The garbage collector monitors memory allocation, reclaiming memory that is no longer needed. However, since the problem of automatically identifying unused memory is undecidable, garbage collectors provide only an approximate solution.

To avoid memory issues, keep the following checklist in mind:

  1. Timers and Callbacks

  2. Closures

  3. Event Listeners

  4. Detached DOM Elements

  5. API Calls (e.g., React Query, Axios)

Tools for Testing Memory Leaks:

  • Browser DevTools - Memory Tab

  • Memlab by Meta

Conclusion

This issue taught us a lot, including that the React Context API may not always be the best solution for data passing. We also deepened our understanding of Zustand as a state management tool.

The solutions outlined here may seem straightforward, but they came after extensive research and development. This was a team effort—the "Avengers" of development! 😎