← Back

Canvas Service Lifecycle: Fixing Memory Leaks in React

·frontend-explore

Canvas Service Lifecycle: Fixing Memory Leaks in React

Key Takeaway

Our canvas service registered event listeners and intervals that never cleaned up, causing memory leaks and slow performance degradation. Implementing proper cleanup in useEffect return functions eliminated all memory leaks.

The Problem

function CanvasViewer() {
  useEffect(() => {
    const canvas = canvasRef.current;

    // Add listeners - never removed! 🔥
    canvas.addEventListener('mousemove', handleMouseMove);
    canvas.addEventListener('wheel', handleWheel);

    // Start interval - never cleared! 🔥
    const timer = setInterval(() => {
      redrawCanvas();
    }, 16);

    // No cleanup! Memory leak!
  }, []);
}

Issues: Memory usage grew to 2GB after 30 min, event listeners piled up (1000+), intervals ran after unmount, browser eventually crashed.

The Solution

function CanvasViewer() {
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    // Event handlers
    const handleMouseMove = (e: MouseEvent) => {
      // Handle mouse move
    };

    const handleWheel = (e: WheelEvent) => {
      e.preventDefault();
      // Handle zoom
    };

    // Add listeners
    canvas.addEventListener('mousemove', handleMouseMove);
    canvas.addEventListener('wheel', handleWheel, { passive: false });

    // Start interval
    const timer = setInterval(() => {
      redrawCanvas();
    }, 16);

    // CLEANUP FUNCTION
    return () => {
      // Remove event listeners
      canvas.removeEventListener('mousemove', handleMouseMove);
      canvas.removeEventListener('wheel', handleWheel);

      // Clear interval
      clearInterval(timer);

      console.log('Canvas cleaned up');
    };
  }, []);
}

Complete cleanup pattern:

function AdvancedCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const animationFrameRef = useRef<number>();
  const observerRef = useRef<ResizeObserver>();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    // Event listeners
    const eventHandlers = {
      mousemove: (e: MouseEvent) => handleMouseMove(e),
      mousedown: (e: MouseEvent) => handleMouseDown(e),
      mouseup: (e: MouseEvent) => handleMouseUp(e),
      wheel: (e: WheelEvent) => { e.preventDefault(); handleWheel(e); }
    };

    // Register all listeners
    Object.entries(eventHandlers).forEach(([event, handler]) => {
      canvas.addEventListener(event, handler as any, { passive: false });
    });

    // Animation loop
    const animate = () => {
      renderCanvas();
      animationFrameRef.current = requestAnimationFrame(animate);
    };
    animate();

    // Resize observer
    observerRef.current = new ResizeObserver(() => {
      handleResize();
    });
    observerRef.current.observe(canvas);

    // COMPREHENSIVE CLEANUP
    return () => {
      // Remove all event listeners
      Object.entries(eventHandlers).forEach(([event, handler]) => {
        canvas.removeEventListener(event, handler as any);
      });

      // Cancel animation frame
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }

      // Disconnect observer
      if (observerRef.current) {
        observerRef.current.disconnect();
      }

      // Clear canvas context
      const ctx = canvas.getContext('2d');
      if (ctx) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
      }

      console.log('Complete cleanup performed');
    };
  }, []);
}

Impact

| Metric | Before | After | |--------|--------|-------| | Memory after 30min | 2GB | 120MB | | Active event listeners | 1000+ | 10 | | Running intervals | 50+ | 1 | | Memory leaks | 15 | 0 |

Lessons Learned

  1. Always Cleanup: Every effect should return cleanup function
  2. Remove Listeners: Pairs with addEventListener
  3. Cancel Timers: clearInterval, clearTimeout, cancelAnimationFrame
  4. Disconnect Observers: ResizeObserver, IntersectionObserver, MutationObserver
  5. Clear References: Set refs to null if needed