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
- Always Cleanup: Every effect should return cleanup function
- Remove Listeners: Pairs with addEventListener
- Cancel Timers: clearInterval, clearTimeout, cancelAnimationFrame
- Disconnect Observers: ResizeObserver, IntersectionObserver, MutationObserver
- Clear References: Set refs to null if needed