React Native

Preventing Memory Leaks in React Native Screens With Cleanup Patterns

4 min read by DebuggedIt

Quick answer

Quick answer: most React Native memory leaks come from things a screen starts and never stops: event listeners, timers, subscriptions, and in-flight requests....

Quick answer: most React Native memory leaks come from things a screen starts and never stops: event listeners, timers, subscriptions, and in-flight requests. Return a cleanup function from every useEffect that creates one, cancel requests with an abort signal, and remember that navigation stacks keep screens mounted, so some work should run only while a screen is focused.

Where Do Leaks Come From in React Native?

A leak happens when memory that is no longer needed stays reachable, so the garbage collector cannot free it. In React Native, the usual cause is a long-lived object holding a reference to a component, directly or through a closure. When the screen unmounts, the component should disappear, but the listener or timer keeps it alive, along with its state and any large data it captured.

The most common sources are event emitters and subscriptions, such as app state changes, keyboard events, device sensors, and connectivity listeners. Others include setInterval and setTimeout callbacks, WebSocket connections, animation loops, and observers from third-party libraries. Each of these outlives the component unless you remove it.

Leaks are gradual. A single screen may hold a few kilobytes, but a user who opens the same screen dozens of times can accumulate enough to slow the app or trigger the operating system to terminate it, especially on devices with limited memory.

Which Cleanup Patterns Should You Use?

The core pattern is symmetric setup and teardown inside useEffect. Whatever the effect starts, its returned function must stop:

useEffect(() => {
  const sub = AppState.addEventListener('change', handleChange);
  const id = setInterval(refresh, 30000);
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(() => {});

  return () => {
    sub.remove();
    clearInterval(id);
    controller.abort();
  };
}, []);

These habits keep the pattern reliable across a codebase:

  • Use the removal method returned by modern listener APIs instead of the old removeListener calls, which some versions no longer support.
  • Cancel fetch requests with an AbortController, or ignore results after unmount with a flag, so a late response does not update dead state.
  • Clear every interval and timeout, and cancel animations and gesture handlers started by the screen.
  • Close WebSocket connections and unsubscribe from store or observable subscriptions on unmount.
  • Keep effect dependency arrays correct so cleanup and setup run when values change, not stale closures.

Why Do Navigation Stacks Complicate This?

In a stack navigator, pushing a new screen does not unmount the previous one. It stays mounted underneath, with its effects still running. A screen that polls every few seconds keeps polling while hidden, which wastes battery, memory, and network, even if nothing leaks in the strict sense.

Most navigation libraries provide a focus-aware effect hook that runs when the screen gains focus and cleans up when it loses it. Use it for polling, subscriptions to live data, and sensors that only matter while the screen is visible. Reserve plain useEffect for work tied to the component's whole lifetime. Check your navigation library's documentation for the exact hooks and behavior.

Also be careful with large objects in global state, caches, and module-level variables. They live as long as the app, so unbounded caches of images or API responses grow forever unless you cap them and evict old entries.

How Do You Find Leaks?

Use the profiling tools provided with your engine and platform, such as heap snapshots and allocation tracking, and compare memory after repeatedly opening and closing a screen. If memory climbs and does not return after garbage collection, something retains it. Look for retained component instances and their reference paths. Test on real, lower-end devices, since simulators can hide pressure.

Frequently Asked Questions

Does React Native warn about state updates after unmount?

Older React versions logged a warning, and newer versions removed it. The underlying problem still exists, because the callback keeps the component reachable, so cleanup is still necessary.

Do I need to clean up every useEffect?

Only effects that create something persistent, such as listeners, timers, subscriptions, or requests. Effects that merely compute values do not need cleanup.

Can images cause memory problems?

Yes. Large decoded images use significant memory. Resize images to display size, use caching carefully, and avoid keeping many full-resolution images mounted at once.

Why does my hidden screen still run code?

Stack navigators keep previous screens mounted. Use focus-aware effects for work that should run only while the screen is visible.

Summary

Prevent leaks by pairing every listener, timer, subscription, and request with cleanup, using abort signals for network calls and focus-aware effects for visible-only work. Cap caches and profile on real devices. Consult the documentation of your React Native and navigation versions for exact APIs.