React is one of the most powerful JavaScript libraries for building modern web applications. But even the most experienced React developers can unknowingly introduce memory leaks that silently degrade performance, cause sluggish UIs, and eventually crash browser tabs.
The tricky part? Most memory leaks in React don’t throw errors. They just quietly consume more and more memory in the background until your users start noticing.
In this article, we’ll uncover 5 hidden memory leaks commonly found in React applications and show you exactly how to fix them, with practical, real-world code examples.
What Is a Memory Leak in React?
A memory leak occurs when your application holds onto memory that is no longer needed. In React, this typically happens when a component is unmounted but some ongoing process — like a timer, event listener, or async call, still holds a reference to it and tries to update its state.
Over time, these unreleased references pile up, consuming system resources and degrading application performance.
Memory Leak #1: setState Called After Component Unmounts
The Problem
This is the most common memory leak in React. It happens when an asynchronous operation (like a fetch call or setTimeout) completes after a component has already been unmounted and then tries to update the state of that component.
// ❌ Problematic Code
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // ⚠️ Component might be unmounted by now
}, [userId]);
return <div>{user?.name}</div>;
}
If the user navigates away before the fetch completes, the component unmounts — but the .then() callback still fires and calls setUser(), causing a React warning and a memory leak.
The Fix
Use a cleanup flag inside useEffect to cancel the state update if the component has unmounted:
// ✅ Fixed Code
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = true;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (isMounted) setUser(data); // ✅ Only update if still mounted
});
return () => {
isMounted = false; // Cleanup on unmount
};
}, [userId]);
return <div>{user?.name}</div>;
}
For more modern projects, you can also use the AbortController API to cancel the fetch request entirely on unmount.
Memory Leak #2: Uncleared setInterval and setTimeout
The Problem
Timers created with setInterval or setTimeout inside a useEffect don’t automatically stop when a component unmounts. If the component is gone but the timer keeps running, it still references component state or props that should have been garbage collected.
// ❌ Problematic Code
import { useState, useEffect } from 'react';
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
setInterval(() => {
setTime(new Date()); // ⚠️ Runs forever, even after unmount
}, 1000);
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
Every time the component mounts and unmounts, a new interval is created. These stack up and continue running indefinitely.
The Fix
Always return a cleanup function from useEffect to clear the timer when the component unmounts:
// ✅ Fixed Code
import { useState, useEffect } from 'react';
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(timer); // ✅ Clears timer on unmount
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
The same principle applies to setTimeout — always clearTimeout in the cleanup function.
Memory Leak #3: Forgotten Event Listeners
The Problem
Attaching event listeners to window, document, or DOM elements without removing them when the component unmounts is a classic source of memory leaks. These listeners keep references to component functions (closures), preventing garbage collection.
// ❌ Problematic Code
import { useEffect } from 'react';
function ResizeTracker() {
useEffect(() => {
const handleResize = () => {
console.log('Window resized:', window.innerWidth);
};
window.addEventListener('resize', handleResize); // ⚠️ Never removed
}, []);
return <div>Tracking resize...</div>;
}
Every time ResizeTracker mounts, a new event listener is added. If it mounts and unmounts multiple times (e.g., in a tabbed UI), dozens of stale listeners accumulate.
The Fix
Return a cleanup function that removes the event listener:
// ✅ Fixed Code
import { useEffect } from 'react';
function ResizeTracker() {
useEffect(() => {
const handleResize = () => {
console.log('Window resized:', window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize); // ✅ Properly removed
};
}, []);
return <div>Tracking resize...</div>;
}
This ensures the exact same function reference is used for both add and remove.
Memory Leak #4: Unsubscribed Observables and WebSocket Connections
The Problem
If your React app uses RxJS observables, WebSocket connections, or custom pub/sub patterns, failing to unsubscribe on unmount leads to serious memory leaks. The subscription holds onto a reference to the component, keeping it alive in memory even after it’s been removed from the DOM.
// ❌ Problematic Code (RxJS Example)
import { useEffect, useState } from 'react';
import { interval } from 'rxjs';
function DataStream() {
const [count, setCount] = useState(0);
useEffect(() => {
const subscription = interval(1000).subscribe(val => {
setCount(val); // ⚠️ Subscription never unsubscribed
});
}, []);
return <p>Count: {count}</p>;
}
The Fix
Always unsubscribe from observables and close WebSocket connections in the cleanup function:
// ✅ Fixed Code (RxJS Example)
import { useEffect, useState } from 'react';
import { interval } from 'rxjs';
function DataStream() {
const [count, setCount] = useState(0);
useEffect(() => {
const subscription = interval(1000).subscribe(val => {
setCount(val);
});
return () => {
subscription.unsubscribe(); // ✅ Clean up the subscription
};
}, []);
return <p>Count: {count}</p>;
}
For WebSockets:
useEffect(() => {
const socket = new WebSocket('wss://example.com/socket');
socket.onmessage = (event) => {
// handle message
};
return () => {
socket.close(); // ✅ Close connection on unmount
};
}, []);
Memory Leak #5: Closures Capturing Stale References in Long-Lived Callbacks
The Problem
This one is subtle and often missed. When you create a callback function inside useEffect or useCallback without properly listing dependencies, old closures can capture stale references — particularly to objects and arrays. These stale references keep old versions of state or props in memory, preventing garbage collection.
// ❌ Problematic Code
import { useEffect, useRef } from 'react';
function DataLogger({ data }) {
const logData = () => {
console.log('Current data:', data); // ⚠️ Captures stale `data`
};
useEffect(() => {
const timer = setInterval(logData, 2000);
return () => clearInterval(timer);
}, []); // ⚠️ Missing `data` as a dependency
return <div>Logging...</div>;
}
Because data is not listed as a dependency, logData always references the initial value of data. Even worse, if data is a large object, the old version is kept alive in memory because the stale closure holds onto it.
The Fix
Use a useRef to always have access to the latest value without creating stale closures:
// ✅ Fixed Code
import { useEffect, useRef } from 'react';
function DataLogger({ data }) {
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data; // ✅ Always keep ref up to date
}, [data]);
useEffect(() => {
const timer = setInterval(() => {
console.log('Current data:', dataRef.current); // ✅ Always fresh value
}, 2000);
return () => clearInterval(timer);
}, []); // ✅ Safe to have empty deps now
return <div>Logging...</div>;
}
This pattern decouples the stable timer callback from the frequently changing data value.
How to Detect Memory Leaks in React
Knowing how to fix memory leaks is great — but being able to detect them proactively is even better. Here are the most effective tools and approaches:
Chrome DevTools Memory Tab — Take heap snapshots before and after component unmount. If memory isn’t releasing, something is holding a reference.
React DevTools Profiler — Identify components that are re-rendering unexpectedly or not unmounting properly.
why-did-you-render — A popular npm package that helps you identify unnecessary re-renders that could contribute to performance and memory issues.
Console Warnings — React itself will warn you with the message “Can’t perform a React state update on an unmounted component.” This is almost always a sign of a leak.
Summary: Quick Reference Checklist
Before you ship your next React component, run through this checklist:
- Did you clear all
setIntervalandsetTimeouttimers inuseEffectcleanup? - Did you remove all
addEventListenercalls with the matchingremoveEventListener? - Did you cancel in-flight fetch requests or mark them with an
isMountedflag? - Did you unsubscribe from all RxJS observables and close WebSocket connections?
- Did you check your
useEffectdependency arrays for missing or incorrectly listed dependencies?
Final Thoughts
Memory leaks are one of those issues that can quietly turn a fast, responsive React application into a sluggish one that frustrates users. The good news is that all five of the leaks we covered follow predictable patterns — and once you know what to look for, they’re straightforward to fix.
At Stinlief Technologies, we build scalable, high-performance React applications that are engineered to avoid these pitfalls from the ground up. Whether you’re dealing with a performance issue in an existing product or building something new, our team has the expertise to deliver clean, maintainable code.
Have questions about React performance or want a free code review? Contact us today.


