React Native Memory Leaks: Tracing JS Heap and Native Memory Owners
Learn why garbage collection cannot save a React Native app from native leaks, and how to find what keeps callbacks, JSI objects and decoded images alive.
This article is published in English.
Sources: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory
A React Native screen unmounts, and you reasonably expect its memory to go with it. Yet after opening and closing that screen ten times, the app feels sluggish, a low-memory Android phone kills it outright, and Xcode reports that the app exceeded its memory limit. JavaScript has a garbage collector, so how can this happen?
The answer is that a React Native app lives in two memory worlds, and the garbage collector only governs one of them. This guide explains how the stack, the heap and garbage collection actually behave, walks through three leak patterns specific to React Native plus a common hooks misconception, and shows how to use Hermes, Xcode and Android Studio tooling to find whatever still owns the memory that keeps growing.
Two memory worlds that keep each other alive
On one side is the JavaScript world: objects, arrays, functions, React state and props, and the closures your callbacks capture. On the other is the native world: platform views, decoded image pixels, camera and video buffers, file handles and data held by native modules.
The two are linked, and each can keep the other alive. A tidy JavaScript heap says nothing about total app memory, and a component unmounting does not by itself release any native resource it touched. Keeping that separation in mind is the foundation for every diagnosis below.
How the stack, the heap and garbage collection work
Three concepts are worth pinning down precisely.
- The call stack tracks the functions executing right now. Each call pushes a small frame; returning pops it.
- The heap is where JavaScript typically stores objects, arrays, functions and strings that may outlive a single call.
- The garbage collector (GC) looks for heap data that no live reference can reach any more, and reclaims that memory for reuse.
A tiny example makes the relationship concrete:
function showName(user) {
return user.name.toUpperCase();
}
showName({ name: "Ada" });
While toUpperCase() runs, the stack holds a couple of frames: the call to showName and the string method it invokes. The user object itself sits on the heap. When showName returns, its frame is gone, but the object only becomes collectible if nothing else still references it. In this snippet nothing does, so it is eligible for collection; if a cache, a listener or a module-level variable had kept a reference, it would stay.
That gives the rule that governs everything else: the GC reclaims unreachable data. A component unmounting or a local variable leaving scope does not free anything on its own; it only removes one possible path to the data.
Timing matters too. The GC makes no promise about when it runs. Hermes uses a concurrent collector called Hades, designed to keep pauses short so the UI stays smooth (see the Hermes releases). A consequence is that memory not dropping immediately after navigation is not proof of a leak. As Hermes engineers explain in a discussion on GC behavior, unreachable objects may linger until the collector judges that memory pressure makes a collection worthwhile. A leak is memory that keeps climbing across repeated runs of the same journey, not memory that is slow to fall.
Where memory stays alive in a React Native app
Most React Native apps run on Hermes today, though the exact engine depends on the app and its configuration. The engine affects what heap numbers and GC pauses look like, but not the first debugging question: is the growth in JavaScript memory or native memory?
The diagram below shows the typical relationships across the boundary:
JavaScript runtime Native runtime
------------------ --------------------
state and props ─────► views and view layers
arrays and objects ─────► decoded image pixels
hooks and callbacks ─────► camera/video buffers
JSI values ◄─────► TurboModules / C++ data
State and props drive native views, arrays and objects reference decoded images, hooks and callbacks hold camera or video buffers, and JSI values interact in both directions with TurboModules and C++ data. Treat the boundary as an ownership line. Whenever a resource crosses it, you should be able to answer three questions:
- Who owns this resource right now?
- What is keeping it alive?
- Which specific call releases it?
If the answer to the third question is "the garbage collector will handle it", treat that as a warning sign. For native resources, it frequently is not safe.
Leak 1: a native module holding your JavaScript callback
This pattern has no real equivalent in browser JavaScript. A native module can store a callback it received from JavaScript, and that callback keeps its entire closure alive: state, props, a large draft object, even the model of a screen that no longer exists.
Here an effect subscribes to upload progress and returns a cleanup that removes the subscription:
useEffect(() => {
const sub = UploadManager.onProgress((done) => {
reportProgress(draft.id, done);
});
return () => sub.remove();
}, [draft.id]);
This is only safe if remove() genuinely tells the native side to release the callback. If the cleanup is missing, or the library has a bug in it, a retention chain like this one forms:
native UploadManager → JS callback → closure → draft → image list
The native UploadManager holds the callback, the callback holds its closure, the closure holds draft, and draft holds an entire list of images. Unmounting the screen does nothing to break that chain. Only the subscription cleanup does, which is why it is worth verifying that third-party subscriptions actually detach on the native side, not just in JavaScript.
Leak 2: a small JSI object fronting a large native resource
JSI lets JavaScript interact with native objects far more directly than the old bridge, which is excellent for performance and makes ownership much more important. Picture a library that opens a native video decoder, a database cursor or a camera session and hands JavaScript a lightweight object representing it. The JavaScript object may be tiny, and the GC has no reason to hurry to collect it, while the native resource behind it consumes a great deal of memory.
The effect below opens a camera session, starts it, and in cleanup both stops and closes it:
useEffect(() => {
const session = CameraSession.open();
session.start();
return () => {
session.stop();
session.close();
};
}, []);
The close() call is the one that matters. Hardware sessions, decoders, open files, cursors and GPU allocations should all be released deliberately, never left for the collector to find. You cannot schedule a collection, so it cannot serve as your release plan, and finalizers may run late or not at all.
If you maintain a JSI or native library, give every resource-owning object an explicit release method and make it idempotent, so calling it twice is harmless. In application code, call it from the matching cleanup point. The Hermes team gives the same advice in the GC discussion: expose a close()-style method for native resources rather than depending on finalization.
Leak 3: the image component is gone, the pixels are not
This pattern is behind a large share of out-of-memory crashes. A photo that is modest on disk becomes enormous once decoded, because compressed formats like JPEG must be expanded into raw pixels for display. A rough estimate of decoded size is:
width × height × 4 bytes
At four bytes per pixel, a 4,000 by 3,000 photo needs around 48 MB, and that is before any extra copies for scaling, caching or GPU upload. Now consider a thumbnail that renders such a photo:
<Image
source={{ uri: fullSizePhotoUrl }}
style={{ width: 160, height: 160 }}
/>
The view is only 160 by 160 points, but the device may still download and decode the full-resolution file behind it. In a feed with many large images, native memory can climb steadily while the JavaScript heap stays flat, which is exactly the situation that confuses developers who only look at JS memory.
Not every rise is a leak. An image cache that holds recently viewed pictures is doing its job. It becomes a problem when memory grows on every pass through the same flow, never levels off and ends with the process being killed.
Start by fixing what you feed into the image pipeline:
- Request images close to the size at which they will be displayed.
- Use thumbnails in lists and load full resolution only on the detail screen.
- Cap how many image views are mounted at once, for example through list virtualization.
- Learn the caching rules of your image library and configure its limits.
- Test on a real low-memory device, not only on a simulator.
Blaming Hermes for a 48 MB bitmap misses the point. The JavaScript engine cannot reclaim native pixels still owned by an image view, the image pipeline, a cache or a GPU texture.
Stale closures in hooks: know which bug you have
A common claim is that a wrong dependency array leaks memory. It can, but more often it first produces a wrong-value bug, and the distinction affects how you fix it.
A closure retains the values it references. If a native listener or a timer holds that closure longer than intended, the closure keeps old data alive with it. In this effect, the dependency array is empty, so the progress callback is created once:
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, []); // episode may change, but this callback will not
The callback captures episode from the first render. When the user switches episodes, progress is still saved against the original ID. That is a stale closure. If the subscription is removed on unmount, you have a correctness bug rather than a lasting leak. If the native player keeps the callback after unmount, you have both.
The fix is not to throw every object into the dependency array. It is to make the effect describe exactly the resource it owns, keyed on the value that defines it:
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, [episode.id]);
Now, whenever episode.id changes, React runs the cleanup to remove the old subscription before creating a new one, and the callback captures only the small value it needs. Depending on a primitive ID instead of the whole episode object also avoids resubscribing on every render when the object identity changes but the episode does not.
Running a real memory investigation
Vague observations like "the app uses a lot of memory" are not actionable. Define one repeatable journey instead, for example: open the feed, scroll it, open ten photos, go back, and repeat the whole sequence five times.
Then look at which pattern the measurements show:
- The JS heap grows after each loop: the leak is most likely on the JavaScript side, so start with heap snapshots.
- The JS heap stays roughly flat while total app memory rises: look at native memory with platform tools.
Diagnosing JavaScript heap growth
Use the Hermes inspector and take heap snapshots where your React Native version and build configuration support it. Select a suspicious object in the snapshot and follow its retaining path back to a root. Typical culprits show up with names such as listeners, timers, stores, caches, callbacks or module-level singletons.
Be careful not to confuse this with the Hermes sampling profiler, which is primarily a CPU tool. It shows which JavaScript was busy, not why native image memory is growing. The React Native guide to profiling with Hermes covers how to record and open a CPU profile.
Diagnosing native memory on iOS
Run the app from Xcode on a physical device. Begin with the memory gauge for a quick trend, then open the Debug Memory Graph to see objects and the references between them. Instruments, particularly the Allocations template, lets you watch allocations as you repeat the journey. Apple's Xcode memory documentation describes how the memory graph exposes objects, allocations and their links, and how Allocations tracks both heap and virtual memory.
When Instruments shows steady growth during a photo-heavy flow but the Hermes heap barely moves, stop hunting for a JavaScript closure. The problem is on the native side.
Diagnosing native memory on Android
Use the Android Studio Memory Profiler on a real device to watch the app's memory over time. Its native memory profiling, which arrived with Android Studio 4.1, records native allocations and deallocations and summarizes native objects.
The profiler's screens change between Android Studio versions, but the method does not: record the repeatable journey, mark the start of each loop, and look for memory that climbs without returning to a stable baseline.
A cleanup checklist for every screen
Whatever a screen starts, its cleanup should end. A quick audit covers:
- A timer that was started gets cleared.
- An added listener gets removed.
- A native subscription gets unsubscribed.
- A camera, player, file or database cursor gets stopped and closed.
- A cache you create gets a size limit or eviction policy.
- Large images get smaller source files and a cap on mounted views.
This is not manual memory management in the C sense. You are not freeing arbitrary bytes; you are ending work and handing back resources at the moment your app stops owning them.
Wrapping up
Garbage collection never promised that everything a component touched would vanish when it unmounted. When memory rises, ask two questions in this order: is the growth in the JavaScript heap or in the native process, and what still owns the thing that is growing?
The answer points straight at the fix. A lingering callback means a subscription that was never removed. A native session means a missing close(). A 48 MB bitmap means you need a smaller image or a tighter cache. React Native memory bugs stop being mysterious once you stop waiting for the GC and start finding the owner, then ending its ownership.
Throughout, profile on real devices. A simulator is fine for reproducing a flow, but it can hide the memory pressure that terminates your app on a user's phone.