Your React Native Performance Problem May Actually Be a Memory Leak
A React Native screen can render correctly and still leak memory.
That is why some performance problems are misdiagnosed.
The app feels fine after launch.
Then, after repeated navigation:
- screens become slower
- callbacks fire more than once
- background work keeps increasing
- network listeners remain active
- timers continue running
- state updates happen after a screen has already unmounted
The first reaction is often:
βLet us optimise re-renders.β
But re-rendering may not be the real problem.
The actual issue may be that work created by old screens never stopped.
Why this is easy to misdiagnose
Many React Native performance issues are visible immediately.
A heavy component may:
- render slowly
- block the JavaScript thread
- display a large list inefficiently
- perform expensive calculations during render
Memory leaks often behave differently.
They usually appear gradually.
The first navigation works correctly.
The second also looks fine.
But after opening and closing the same screen multiple times, the application may start doing more work than expected.
That gradual degradation makes the issue look like a rendering problem, even when the rendering logic has not changed.
The important question is not only:
What is React rendering now?
You also need to ask:
What is the previous screen still doing?
A simple failure scenario
Consider a screen that subscribes to an event:
import { useEffect } from 'react';
import { DeviceEventEmitter } from 'react-native';
function StatusScreen() {
useEffect(() => {
DeviceEventEmitter.addListener('device-status', status => {
console.log('Device status:', status);
});
}, []);
return null;
}
At first glance, this code looks harmless.
The subscription is created only once for each mount because the dependency array is empty.
But there is no cleanup.
Each time the screen mounts, another listener is registered.
After navigating to the screen five times, the same event may trigger five callbacks.
The screen may not look broken, but the application is now doing unnecessary work.
A safer version removes the listener during cleanup:
import { useEffect } from 'react';
import { DeviceEventEmitter } from 'react-native';
function StatusScreen() {
useEffect(() => {
const subscription = DeviceEventEmitter.addListener(
'device-status',
status => {
console.log('Device status:', status);
}
);
return () => {
subscription.remove();
};
}, []);
return null;
}
The important improvement is not in the render function.
It is in the lifecycle ownership.
Common sources of memory leaks in React Native
1. Event listeners without cleanup
Listeners are one of the most common causes of duplicated work.
Examples include:
DeviceEventEmitterNativeEventEmitter- keyboard listeners
- dimension listeners
- custom event buses
- navigation listeners
A common mistake is to register the listener correctly but forget to unsubscribe.
useEffect(() => {
const subscription = Keyboard.addListener(
'keyboardDidShow',
handleKeyboardShow
);
return () => {
subscription.remove();
};
}, []);
Every listener should have a clear cleanup path.
2. Timers that survive screen unmount
Intervals are especially risky because they continue executing until explicitly cleared.
useEffect(() => {
const intervalId = setInterval(() => {
refreshStatus();
}, 5000);
return () => {
clearInterval(intervalId);
};
}, []);
Without cleanup, each screen visit may create another interval.
The result may be:
- duplicate API requests
- repeated state updates
- unnecessary battery usage
- unexpected native module calls
The code may still appear correct during a short test session.
The problem becomes visible only after repeated use.
3. AppState and NetInfo subscriptions
App lifecycle and connectivity listeners often live close to application-level logic.
That makes ownership less obvious.
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
handleAppStateChange
);
return () => {
subscription.remove();
};
}, []);
NetInfo has a similar subscription pattern:
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
console.log('Connected:', state.isConnected);
});
return unsubscribe;
}, []);
These subscriptions can be valid at the application level.
The problem starts when the same global-style listener is created inside multiple screens or components.
Before registering one, decide:
- should this listener exist once for the app?
- once per navigator?
- once per screen?
- once per active feature?
The cleanup strategy depends on the ownership boundary.
4. Native module callbacks registered multiple times
Native integration makes memory leaks harder to diagnose because the work may continue outside React.
A native module may:
- emit Bluetooth events
- report sensor data
- receive location updates
- send foreground service events
- notify JavaScript about background tasks
If the JavaScript side registers the callback repeatedly, the native module may continue sending events to multiple active listeners.
useEffect(() => {
const emitter = new NativeEventEmitter(MyNativeModule);
const subscription = emitter.addListener(
'measurement',
handleMeasurement
);
return () => {
subscription.remove();
};
}, []);
For native integrations, also verify whether cleanup is required on the native side.
Removing the JavaScript listener may not be enough if the native service itself keeps running.
Sometimes cleanup also requires calling methods such as:
MyNativeModule.stopMonitoring();
The correct lifecycle may therefore look like this:
useEffect(() => {
MyNativeModule.startMonitoring();
const emitter = new NativeEventEmitter(MyNativeModule);
const subscription = emitter.addListener(
'measurement',
handleMeasurement
);
return () => {
subscription.remove();
MyNativeModule.stopMonitoring();
};
}, []);
This is why memory ownership matters more than simply calling remove().
5. Async work completing after unmount
Async operations do not always create a permanent memory leak, but they can still produce stale work and unnecessary updates.
useEffect(() => {
fetchProfile().then(profile => {
setProfile(profile);
});
}, []);
If the user leaves the screen before the request finishes, the callback may run after unmount.
A simple guard can prevent stale updates:
useEffect(() => {
let isActive = true;
async function loadProfile() {
const profile = await fetchProfile();
if (isActive) {
setProfile(profile);
}
}
loadProfile();
return () => {
isActive = false;
};
}, []);
For network requests, cancellation is often better when supported.
With fetch, you can use AbortController:
useEffect(() => {
const controller = new AbortController();
async function loadProfile() {
try {
const response = await fetch('/profile', {
signal: controller.signal,
});
const data = await response.json();
setProfile(data);
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error(error);
}
}
}
loadProfile();
return () => {
controller.abort();
};
}, []);
This avoids unnecessary work instead of only ignoring the result.
The ownership questions every subscription should answer
For every listener, timer, subscription or callback, identify four things.
1. Where is it created?
Is it created inside:
- a screen
- a custom hook
- a context provider
- a navigation container
- an app-level service
This tells you where cleanup should probably live.
2. Who owns it?
Ownership should be explicit.
A screen-level listener should normally disappear with the screen.
An application-level listener may intentionally survive navigation.
The problem is not long-lived work itself.
The problem is long-lived work without clear ownership.
3. When is it removed?
Do not rely on assumptions.
The cleanup path should be visible and testable.
For React components, this often means returning a cleanup function from useEffect.
useEffect(() => {
const resource = createResource();
return () => {
resource.dispose();
};
}, []);
4. Can it be registered more than once?
This question catches many production issues.
A listener may be duplicated because:
- the component mounts repeatedly
- the effect dependency changes
- a navigation listener is registered during focus
- a retry flow reinitialises the subscription
- a native module setup method is called more than once
Always verify whether the registration is idempotent.
useEffect dependencies can create hidden duplication
A cleanup function alone does not guarantee correct behaviour.
Consider this effect:
useEffect(() => {
const subscription = eventBus.subscribe(userId, handleEvent);
return () => {
subscription.unsubscribe();
};
}, [userId, handleEvent]);
If handleEvent is recreated on every render, the effect may unsubscribe and resubscribe repeatedly.
That may be valid, but it may also create unnecessary churn.
Use useCallback when stable function identity is important:
const handleEvent = useCallback((event: EventData) => {
console.log(event);
}, []);
useEffect(() => {
const subscription = eventBus.subscribe(userId, handleEvent);
return () => {
subscription.unsubscribe();
};
}, [userId, handleEvent]);
The debugging question is not simply:
Does this effect have cleanup?
It is:
How often does this effect run, and how often does it recreate the resource?
Focus listeners require extra care
React Navigation screens may remain mounted even when they are not focused.
That means screen lifecycle and focus lifecycle are not always the same.
If work should run only while the screen is active, useFocusEffect may be a better fit.
import { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
useFocusEffect(
useCallback(() => {
const subscription = subscribeToLiveData();
return () => {
subscription.unsubscribe();
};
}, [])
);
This ensures the subscription is active only while the screen is focused.
Use this carefully.
If the subscription should survive temporary navigation changes, screen-level focus cleanup may be too aggressive.
The correct decision depends on ownership.
Why render optimisation alone may not help
Suppose you apply:
React.memouseMemouseCallback- list virtualisation
- smaller component trees
These changes can improve rendering.
But they do not remove:
- old listeners
- duplicate timers
- native callbacks
- background services
- stale async tasks
You may make the current screen render faster while previous screens continue consuming resources.
That is why profiling only render behaviour can give an incomplete picture.
Render profiling tells you what React is doing now.
Lifecycle debugging tells you what earlier components may still be doing.
You need both.
A practical debugging process
When a React Native app becomes slower after repeated navigation, use a repeatable process.
Step 1: Reproduce the degradation
Do not test only from a fresh launch.
Repeat the same flow:
- open the screen
- leave the screen
- open it again
- repeat several times
- trigger the same event or action
Look for callbacks that increase with every cycle.
Step 2: Add temporary registration logs
Log when resources are created and removed.
useEffect(() => {
console.log('Registering device listener');
const subscription = registerDeviceListener();
return () => {
console.log('Removing device listener');
subscription.remove();
};
}, []);
For each registration, you should see a corresponding cleanup when expected.
Step 3: Count callback executions
const callbackCount = useRef(0);
function handleEvent() {
callbackCount.current += 1;
console.log('Callback count:', callbackCount.current);
}
If a single event triggers multiple callbacks after repeated navigation, duplicate listeners are likely.
Step 4: Inspect timers and polling
Search for:
setIntervalsetTimeout- recursive polling
- retry loops
- animation loops
Confirm that each one is cancelled correctly.
Step 5: Inspect native services
Check whether native modules continue:
- scanning
- observing
- polling
- emitting events
- tracking location
- maintaining sockets
JavaScript cleanup may not stop native work automatically.
Step 6: Profile memory and activity
Use platform tools when the issue is not obvious.
For Android:
- Android Studio Profiler
- Memory Profiler
- CPU Profiler
- Logcat
For iOS:
- Xcode Instruments
- Allocations
- Leaks
- Time Profiler
- Energy Log
Also use React Native tooling where useful:
- React DevTools
- Hermes profiling
- Flipper, where supported by the project setup
The goal is not only to see memory growth.
Also check whether the number of callbacks, subscriptions or native operations grows with each navigation cycle.
A cleanup checklist for code review
When reviewing React Native lifecycle code, ask:
- Does every listener have an unsubscribe path?
- Does every interval have a
clearInterval? - Does every timeout have a
clearTimeoutwhere required? - Does every native subscription expose disposal?
- Does every background service have a stop method?
- Can this effect register more than once?
- Should this resource follow mount lifecycle or focus lifecycle?
- Can async work be cancelled?
- Can stale results update state?
- Is this subscription owned by the correct architectural layer?
This checklist is more useful than asking only whether useEffect has a return function.
Cleanup is not just syntax.
It is an ownership decision.
A stronger engineering rule
Before optimising rendering, check whether the screen leaves behind any long-lived work.
For every subscription, answer:
- Where is it created?
- Who owns it?
- When is it removed?
- Can it be registered more than once?
A component may render perfectly and still damage performance over time.
The leak often exists outside the visible render path.
That is why repeated navigation is such an important test.
A fresh launch shows whether the screen works.
Repeated use shows whether the lifecycle works.
Final takeaway
Not every slow React Native screen needs render optimisation.
Sometimes the screen is slow because previous screens are still alive through:
- listeners
- timers
- subscriptions
- native callbacks
- background tasks
Render profiling tells you what React is doing now.
Lifecycle cleanup tells you what old work never stopped.
When a React Native app becomes slower after repeated navigation, which cleanup path do you inspect first?
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.