Steve Kinney

Memory Leak Detection in React Applications

Memory leaks in React applications are silent killers. Your app launches smoothly, but after an hour of use, it consumes 500MB of RAM and feels sluggish. Users navigate between pages, components mount and unmount, but something holds onto memory that should have been freed. The garbage collector runs, but memory usage keeps climbing. Eventually, the tab crashes or the mobile browser kills your app.

The insidious nature of memory leaks makes them particularly dangerous—they’re invisible during development but catastrophic in production. A single event listener that’s never removed, a closure that captures a large object, or a timer that never gets cleared can transform a snappy React app into a memory-hungry monster. The key is systematic detection, understanding common patterns, and building defensive coding practices.

Understanding Memory Leaks in React

Memory leaks occur when your JavaScript code holds references to objects that should be garbage collected. For a deep dive into how JavaScript memory management and garbage collection work, see Memory Management Deep Dive.

In React applications, memory leaks typically follow these patterns:

  • Event listeners that aren’t removed on component unmount
  • Timers and intervals that continue running after component cleanup
  • Closures that capture large objects unnecessarily
  • Global references to component callbacks or state
  • Subscriptions to external services without proper cleanup
// Quick examples of leak-prone patterns
function ProblematicComponent({ data }: { data: LargeData[] }) {
  useEffect(() => {
    // ❌ Event listener leak
    window.addEventListener('scroll', handleScroll);
    // Missing cleanup

    // ❌ Timer leak
    const interval = setInterval(updateData, 1000);
    // Missing clearInterval

    // ❌ Subscription leak
    const subscription = dataService.subscribe(handleUpdate);
    // Missing unsubscribe
  }, []);

  // ❌ Closure leak - captures entire data array
  const processItem = useCallback(
    (id: string) => {
      return data.find((item) => item.id === id);
    },
    [data],
  );

  return <div>Component content</div>;
}

The key to prevention is systematic cleanup and understanding which patterns create persistent references.

Chrome DevTools Memory Profiling

Chrome DevTools provides powerful tools for detecting memory leaks:

Taking Memory Snapshots

// Memory profiling utility for development
class MemoryProfiler {
  private snapshots: Array<{
    name: string;
    timestamp: number;
    heapSize: number;
  }> = [];

  takeSnapshot(name: string): void {
    if (process.env.NODE_ENV !== 'development') return;

    // Force garbage collection if available
    if (window.gc) {
      window.gc();
    }

    const heapSize = (performance as any).memory?.usedJSHeapSize || 0;

    this.snapshots.push({
      name,
      timestamp: Date.now(),
      heapSize,
    });

    console.log(`📸 Memory snapshot "${name}": ${(heapSize / 1024 / 1024).toFixed(2)}MB`);
  }

  compareSnapshots(before: string, after: string): void {
    const beforeSnapshot = this.snapshots.find((s) => s.name === before);
    const afterSnapshot = this.snapshots.find((s) => s.name === after);

    if (!beforeSnapshot || !afterSnapshot) {
      console.error('Snapshots not found');
      return;
    }

    const diff = afterSnapshot.heapSize - beforeSnapshot.heapSize;
    const diffMB = diff / 1024 / 1024;

    console.log(`🔍 Memory comparison "${before}" → "${after}": ${diffMB.toFixed(2)}MB`);

    if (diffMB > 5) {
      console.warn(`⚠️ Potential memory leak detected: ${diffMB.toFixed(2)}MB increase`);
    }
  }

  generateReport(): string {
    if (this.snapshots.length === 0) return 'No snapshots available';

    let report = '# Memory Usage Report\n\n';

    this.snapshots.forEach((snapshot, index) => {
      const sizeMB = (snapshot.heapSize / 1024 / 1024).toFixed(2);
      report += `${index + 1}. **${snapshot.name}**: ${sizeMB}MB\n`;

      if (index > 0) {
        const prev = this.snapshots[index - 1];
        const diff = ((snapshot.heapSize - prev.heapSize) / 1024 / 1024).toFixed(2);
        report += `   *Change from previous: ${diff}MB*\n`;
      }
    });

    return report;
  }
}

// Global profiler instance for development
const memoryProfiler = new MemoryProfiler();

// React hook for memory profiling
function useMemoryProfiler(componentName: string) {
  useEffect(() => {
    memoryProfiler.takeSnapshot(`${componentName} mounted`);

    return () => {
      memoryProfiler.takeSnapshot(`${componentName} unmounted`);
    };
  }, [componentName]);

  return {
    takeSnapshot: (name: string) => {
      memoryProfiler.takeSnapshot(`${componentName} ${name}`);
    },
    compareSnapshots: memoryProfiler.compareSnapshots.bind(memoryProfiler),
  };
}

// Usage in components
function ProfiledComponent() {
  const { takeSnapshot } = useMemoryProfiler('ProfiledComponent');

  const handleLoadData = async () => {
    takeSnapshot('before data load');

    // Load large dataset
    const data = await fetchLargeDataset();

    takeSnapshot('after data load');
  };

  return (
    <div>
      <button onClick={handleLoadData}>Load Data</button>
    </div>
  );
}

Automated Memory Leak Detection

// Automated memory leak detector
class MemoryLeakDetector {
  private isMonitoring = false;
  private baseline: number = 0;
  private samples: number[] = [];
  private alertThreshold = 50 * 1024 * 1024; // 50MB
  private callbacks: Array<(leak: MemoryLeakInfo) => void> = [];

  interface MemoryLeakInfo {
    currentUsage: number;
    baseline: number;
    leakSize: number;
    samples: number[];
    duration: number;
  }

  startMonitoring(): void {
    if (this.isMonitoring || process.env.NODE_ENV !== 'development') return;

    this.isMonitoring = true;
    this.baseline = this.getCurrentMemoryUsage();
    this.samples = [this.baseline];

    console.log('🔍 Starting memory leak detection...');

    // Monitor memory every 5 seconds
    const interval = setInterval(() => {
      this.checkForLeaks();
    }, 5000);

    // Stop monitoring after 30 minutes
    setTimeout(() => {
      clearInterval(interval);
      this.stopMonitoring();
    }, 30 * 60 * 1000);
  }

  stopMonitoring(): void {
    if (!this.isMonitoring) return;

    this.isMonitoring = false;
    console.log('🛑 Stopped memory leak detection');

    // Generate final report
    this.generateLeakReport();
  }

  onLeakDetected(callback: (leak: MemoryLeakInfo) => void): void {
    this.callbacks.push(callback);
  }

  private checkForLeaks(): void {
    const currentUsage = this.getCurrentMemoryUsage();
    this.samples.push(currentUsage);

    // Keep only last 20 samples
    if (this.samples.length > 20) {
      this.samples = this.samples.slice(-20);
    }

    const leakSize = currentUsage - this.baseline;

    // Check for memory leak
    if (leakSize > this.alertThreshold) {
      const leakInfo: MemoryLeakInfo = {
        currentUsage,
        baseline: this.baseline,
        leakSize,
        samples: [...this.samples],
        duration: this.samples.length * 5000, // 5 seconds per sample
      };

      console.warn(`🚨 Memory leak detected: ${(leakSize / 1024 / 1024).toFixed(2)}MB`);

      // Notify callbacks
      this.callbacks.forEach(callback => callback(leakInfo));
    }
  }

  private getCurrentMemoryUsage(): number {
    if (!(performance as any).memory) return 0;
    return (performance as any).memory.usedJSHeapSize;
  }

  private generateLeakReport(): void {
    if (this.samples.length < 2) return;

    const finalUsage = this.samples[this.samples.length - 1];
    const totalLeak = finalUsage - this.baseline;
    const avgGrowth = totalLeak / this.samples.length;

    console.group('📊 Memory Leak Detection Report');
    console.log(`Initial memory: ${(this.baseline / 1024 / 1024).toFixed(2)}MB`);
    console.log(`Final memory: ${(finalUsage / 1024 / 1024).toFixed(2)}MB`);
    console.log(`Total growth: ${(totalLeak / 1024 / 1024).toFixed(2)}MB`);
    console.log(`Average growth per check: ${(avgGrowth / 1024).toFixed(2)}KB`);
    console.log(`Samples taken: ${this.samples.length}`);

    // Detect trend
    const recentSamples = this.samples.slice(-10);
    const isIncreasing = recentSamples.every((sample, i) =>
      i === 0 || sample >= recentSamples[i - 1]
    );

    if (isIncreasing && totalLeak > 10 * 1024 * 1024) { // > 10MB
      console.warn('⚠️ Consistent memory growth detected - likely memory leak');
    }

    console.groupEnd();
  }
}

// Global detector instance
const leakDetector = new MemoryLeakDetector();

// React hook for component-level leak detection
function useMemoryLeakDetection(componentName: string) {
  useEffect(() => {
    if (process.env.NODE_ENV !== 'development') return;

    let mounted = true;
    let initialMemory = 0;

    // Measure initial memory
    setTimeout(() => {
      if (mounted) {
        initialMemory = (performance as any).memory?.usedJSHeapSize || 0;
      }
    }, 100);

    return () => {
      mounted = false;

      // Check memory after component unmounts
      setTimeout(() => {
        if ((performance as any).memory) {
          const finalMemory = (performance as any).memory.usedJSHeapSize;
          const diff = finalMemory - initialMemory;

          if (diff > 1024 * 1024) { // > 1MB retained
            console.warn(
              `🔍 ${componentName} may have memory leak: ${(diff / 1024 / 1024).toFixed(2)}MB retained after unmount`
            );
          }
        }
      }, 1000);
    };
  }, [componentName]);
}

Common Memory Leak Patterns and Detection

For detailed patterns and advanced optimization techniques, see Memory Management Deep Dive. Here are the key patterns to watch for:

Event Listener Detection

Symptoms: Memory growth during user interactions, especially scrolling or clicking Detection: Check for missing cleanup in useEffect

// ❌ Missing cleanup - red flag during review
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  // No return statement = potential leak
}, []);

// ✅ Proper cleanup pattern
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

Timer Leak Detection

Symptoms: Components updating after unmount, console errors Detection: Check all setInterval and setTimeout calls

// Detection technique: Add component name to timers
useEffect(() => {
  const interval = setInterval(() => {
    console.log('Timer in ComponentName still running'); // This shouldn't appear after unmount
    setCount((prev) => prev + 1);
  }, 1000);

  return () => clearInterval(interval);
}, []);

Closure Leak Detection

Symptoms: Memory growth proportional to data size, sluggish performance Detection: Check dependency arrays in useCallback and useMemo

// ❌ Red flag - large object in dependency array
const processData = useCallback(
  (id: string) => {
    return largeDataset.find((item) => item.id === id);
  },
  [largeDataset],
); // Captures entire dataset

// ✅ Extract only what's needed
const itemMap = useMemo(() => new Map(largeDataset.map((item) => [item.id, item])), [largeDataset]);
const processData = useCallback(
  (id: string) => {
    return itemMap.get(id);
  },
  [itemMap],
);

Subscription Leak Detection

Symptoms: Multiple event handlers firing, unexpected state updates Detection: Use browser dev tools to inspect global event listeners

// Detection helper for development
useEffect(() => {
  const cleanup = subscribeToService(handleData);

  // Add detection in development
  if (process.env.NODE_ENV === 'development') {
    window.__subscriptions = window.__subscriptions || new Set();
    window.__subscriptions.add(cleanup);

    return () => {
      cleanup();
      window.__subscriptions.delete(cleanup);
    };
  }

  return cleanup;
}, []);

Advanced Memory Leak Detection Tools

Custom Memory Profiler Component

// Production-safe memory monitoring
class ProductionMemoryMonitor {
  private isEnabled: boolean;
  private metrics: Array<{
    timestamp: number;
    heapUsed: number;
    component?: string;
  }> = [];

  constructor() {
    // Only enable in development or with explicit flag
    this.isEnabled =
      process.env.NODE_ENV === 'development' ||
      window.location.search.includes('memoryProfile=true');
  }

  recordMetric(component?: string): void {
    if (!this.isEnabled || !(performance as any).memory) return;

    this.metrics.push({
      timestamp: Date.now(),
      heapUsed: (performance as any).memory.usedJSHeapSize,
      component,
    });

    // Keep only last 1000 metrics to prevent memory leak in profiler
    if (this.metrics.length > 1000) {
      this.metrics = this.metrics.slice(-1000);
    }
  }

  analyzeLeaks(): {
    suspiciousComponents: string[];
    overallTrend: 'increasing' | 'stable' | 'decreasing';
    recommendations: string[];
  } {
    if (this.metrics.length < 10) {
      return {
        suspiciousComponents: [],
        overallTrend: 'stable',
        recommendations: ['Need more data points to analyze'],
      };
    }

    // Analyze overall memory trend
    const recentMetrics = this.metrics.slice(-50);
    const oldAverage = recentMetrics.slice(0, 25).reduce((sum, m) => sum + m.heapUsed, 0) / 25;
    const newAverage = recentMetrics.slice(25).reduce((sum, m) => sum + m.heapUsed, 0) / 25;

    const trend =
      newAverage > oldAverage * 1.1
        ? 'increasing'
        : newAverage < oldAverage * 0.9
          ? 'decreasing'
          : 'stable';

    // Analyze component-specific patterns
    const componentMetrics = this.groupMetricsByComponent();
    const suspiciousComponents = this.findSuspiciousComponents(componentMetrics);

    const recommendations = this.generateRecommendations(trend, suspiciousComponents);

    return {
      suspiciousComponents,
      overallTrend: trend,
      recommendations,
    };
  }

  private groupMetricsByComponent(): Map<string, Array<{ timestamp: number; heapUsed: number }>> {
    const grouped = new Map();

    this.metrics.forEach((metric) => {
      if (!metric.component) return;

      if (!grouped.has(metric.component)) {
        grouped.set(metric.component, []);
      }

      grouped.get(metric.component).push({
        timestamp: metric.timestamp,
        heapUsed: metric.heapUsed,
      });
    });

    return grouped;
  }

  private findSuspiciousComponents(
    componentMetrics: Map<string, Array<{ timestamp: number; heapUsed: number }>>,
  ): string[] {
    const suspicious: string[] = [];

    componentMetrics.forEach((metrics, component) => {
      if (metrics.length < 5) return;

      // Check for consistent memory growth after component usage
      const sorted = metrics.sort((a, b) => a.timestamp - b.timestamp);
      const first = sorted[0].heapUsed;
      const last = sorted[sorted.length - 1].heapUsed;

      const growth = (last - first) / first;

      // If memory grew by more than 20% during component's lifetime
      if (growth > 0.2) {
        suspicious.push(component);
      }
    });

    return suspicious;
  }

  private generateRecommendations(trend: string, suspiciousComponents: string[]): string[] {
    const recommendations: string[] = [];

    if (trend === 'increasing') {
      recommendations.push('Overall memory usage is increasing - investigate for memory leaks');
    }

    if (suspiciousComponents.length > 0) {
      recommendations.push(
        `Check these components for memory leaks: ${suspiciousComponents.join(', ')}`,
      );
    }

    recommendations.push('Review event listeners, timers, and subscriptions for proper cleanup');
    recommendations.push('Use Chrome DevTools Memory tab for detailed heap analysis');

    return recommendations;
  }

  exportData(): string {
    return JSON.stringify(
      {
        metrics: this.metrics,
        analysis: this.analyzeLeaks(),
        timestamp: Date.now(),
      },
      null,
      2,
    );
  }
}

// React hook for memory monitoring
function useMemoryMonitoring(componentName: string, enabled = false) {
  const monitor = useRef(new ProductionMemoryMonitor());

  useEffect(() => {
    if (enabled) {
      monitor.current.recordMetric(`${componentName}:mount`);
    }

    return () => {
      if (enabled) {
        monitor.current.recordMetric(`${componentName}:unmount`);
      }
    };
  }, [componentName, enabled]);

  const recordEvent = useCallback(
    (eventName: string) => {
      if (enabled) {
        monitor.current.recordMetric(`${componentName}:${eventName}`);
      }
    },
    [componentName, enabled],
  );

  return {
    recordEvent,
    analyzeLeaks: () => monitor.current.analyzeLeaks(),
    exportData: () => monitor.current.exportData(),
  };
}

// Development tool component
function MemoryProfilerPanel() {
  const [analysis, setAnalysis] = useState<any>(null);
  const monitor = useRef(new ProductionMemoryMonitor());

  const runAnalysis = () => {
    const results = monitor.current.analyzeLeaks();
    setAnalysis(results);
  };

  const exportData = () => {
    const data = monitor.current.exportData();
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = url;
    a.download = `memory-profile-${Date.now()}.json`;
    a.click();

    URL.revokeObjectURL(url);
  };

  if (process.env.NODE_ENV !== 'development') return null;

  return (
    <div
      style={{
        position: 'fixed',
        top: 10,
        right: 10,
        background: '#000',
        color: '#fff',
        padding: 15,
        borderRadius: 5,
        fontSize: 12,
        zIndex: 9999,
      }}
    >
      <h4>Memory Profiler</h4>

      <button onClick={runAnalysis}>Analyze Memory</button>
      <button onClick={exportData}>Export Data</button>

      {analysis && (
        <div>
          <p>Trend: {analysis.overallTrend}</p>
          {analysis.suspiciousComponents.length > 0 && (
            <p>Suspicious: {analysis.suspiciousComponents.join(', ')}</p>
          )}
        </div>
      )}
    </div>
  );
}

Testing for Memory Leaks

// Automated memory leak testing
class MemoryLeakTester {
  async testComponentForLeaks<T>(
    Component: React.ComponentType<T>,
    props: T,
    iterations: number = 100,
  ): Promise<{
    hasLeak: boolean;
    initialMemory: number;
    finalMemory: number;
    leakSize: number;
    averageGrowth: number;
  }> {
    // Force garbage collection before test
    if (window.gc) window.gc();

    const initialMemory = (performance as any).memory?.usedJSHeapSize || 0;
    const memorySnapshots: number[] = [];

    // Mount and unmount component multiple times
    for (let i = 0; i < iterations; i++) {
      const container = document.createElement('div');
      document.body.appendChild(container);

      const root = ReactDOM.createRoot(container);
      root.render(<Component {...props} />);

      // Let component render
      await new Promise((resolve) => setTimeout(resolve, 10));

      root.unmount();
      document.body.removeChild(container);

      // Force garbage collection every 10 iterations
      if (i % 10 === 0 && window.gc) {
        window.gc();

        // Take memory snapshot
        const currentMemory = (performance as any).memory?.usedJSHeapSize || 0;
        memorySnapshots.push(currentMemory);
      }
    }

    // Final garbage collection and measurement
    if (window.gc) window.gc();
    const finalMemory = (performance as any).memory?.usedJSHeapSize || 0;

    const leakSize = finalMemory - initialMemory;
    const averageGrowth =
      memorySnapshots.length > 1
        ? (memorySnapshots[memorySnapshots.length - 1] - memorySnapshots[0]) /
          memorySnapshots.length
        : 0;

    // Consider it a leak if memory grew by more than 1MB
    const hasLeak = leakSize > 1024 * 1024;

    return {
      hasLeak,
      initialMemory,
      finalMemory,
      leakSize,
      averageGrowth,
    };
  }

  async runLeakTestSuite(
    components: Array<{
      name: string;
      Component: React.ComponentType<any>;
      props: any;
    }>,
  ): Promise<void> {
    console.log('🔍 Running memory leak test suite...');

    for (const { name, Component, props } of components) {
      try {
        const results = await this.testComponentForLeaks(Component, props, 50);

        console.group(`📊 ${name} Memory Test Results`);
        console.log(`Initial memory: ${(results.initialMemory / 1024 / 1024).toFixed(2)}MB`);
        console.log(`Final memory: ${(results.finalMemory / 1024 / 1024).toFixed(2)}MB`);
        console.log(`Memory growth: ${(results.leakSize / 1024 / 1024).toFixed(2)}MB`);
        console.log(`Average growth: ${(results.averageGrowth / 1024).toFixed(2)}KB per iteration`);

        if (results.hasLeak) {
          console.error(`❌ MEMORY LEAK DETECTED in ${name}`);
        } else {
          console.log(`✅ No memory leak detected in ${name}`);
        }

        console.groupEnd();
      } catch (error) {
        console.error(`Failed to test ${name}:`, error);
      }
    }
  }
}

// Jest integration
describe('Memory Leak Tests', () => {
  const tester = new MemoryLeakTester();

  it('should not leak memory in UserProfile component', async () => {
    const results = await tester.testComponentForLeaks(UserProfile, { user: mockUser }, 100);

    expect(results.hasLeak).toBe(false);
    expect(results.leakSize).toBeLessThan(1024 * 1024); // < 1MB growth
  });

  it('should detect memory leaks in problematic components', async () => {
    const results = await tester.testComponentForLeaks(LeakyComponent, { data: mockData }, 50);

    // This test expects a leak to verify our detection works
    expect(results.hasLeak).toBe(true);
  });
});

Prevention Strategies

ESLint Rules for Memory Safety

// .eslintrc.js - Custom rules for memory leak prevention
module.exports = {
  rules: {
    // Warn about missing effect cleanup
    'react-hooks/exhaustive-deps': 'warn',

    // Custom rule to detect potential timer leaks
    'custom/timer-cleanup': 'error',

    // Custom rule to detect event listener patterns
    'custom/event-listener-cleanup': 'error',
  },

  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      rules: {
        // TypeScript-specific memory safety rules
        '@typescript-eslint/no-unused-vars': 'error',
      },
    },
  ],
};

// Custom ESLint rule for timer cleanup
const timerCleanupRule = {
  create(context) {
    return {
      CallExpression(node) {
        // Check for setInterval/setTimeout without cleanup
        if (node.callee.name === 'setInterval' || node.callee.name === 'setTimeout') {
          // Look for cleanup in return statement
          const useEffectNode = findParentUseEffect(node);
          if (useEffectNode && !hasCleanupReturn(useEffectNode)) {
            context.report({
              node,
              message: 'Timer should be cleared in useEffect cleanup function',
            });
          }
        }
      },
    };
  },
};

Memory-Safe Component Patterns

// Template for memory-safe React components
function MemorySafeComponent({ data, onUpdate }: { data: any[]; onUpdate: (data: any) => void }) {
  // 1. Use refs for stable references to avoid closure captures
  const dataRef = useRef(data);
  const onUpdateRef = useRef(onUpdate);

  // Update refs when props change
  useEffect(() => {
    dataRef.current = data;
    onUpdateRef.current = onUpdate;
  });

  // 2. Memoize expensive computations
  const processedData = useMemo(() => {
    return data.map((item) => ({
      id: item.id,
      name: item.name,
      // Only include fields you actually need
    }));
  }, [data]);

  // 3. Use custom hooks for subscriptions/timers
  useInterval(() => {
    // Periodic updates using refs to avoid closure capture
    const currentData = dataRef.current;
    const currentOnUpdate = onUpdateRef.current;

    // Process and update
    currentOnUpdate(processData(currentData));
  }, 30000);

  // 4. Clean up all subscriptions
  useEffect(() => {
    const subscription = dataService.subscribe((newData) => {
      onUpdateRef.current(newData);
    });

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  return (
    <div>
      {processedData.map((item) => (
        <ItemComponent key={item.id} item={item} />
      ))}
    </div>
  );
}

Last modified on .