skip to content

Speed & Memory Tuning

Keeping a React Native app smooth and lean: frame budgets on two threads, re-render hot spots, startup time, profiling, leaks and native build times. Interviewers expect measurement first.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

26

In React Native, how do you clean up an AppState listener and a setInterval inside useEffect, and what leaks if you forget?

level: juniorimportance: must knowfreq 58%

answer

  1. the add call returns something
  2. subscription.remove() in the cleanup
  3. clearInterval with the saved id
  4. removeEventListener is gone
  5. closure keeps the screen's state alive

basics

~20 s

Keep the subscription that AppState.addEventListener returns and call its remove() in the effect's cleanup, and clearInterval the saved interval id there too. Otherwise the handlers keep running and keep the unmounted screen's closures, state and data in memory.

solid answer

~40 s

React Native's event APIs return a subscription: `AppState.addEventListener('change', handler)` gives an `EventSubscription`, and so do `Keyboard.addListener`, `Dimensions.addEventListener` and `Linking.addEventListener`; `BackHandler.addEventListener` returns an object with `remove()` as well. I store it and call `subscription.remove()` in the `useEffect` cleanup — the old `removeEventListener`-style methods are gone, `BackHandler`'s last in 0.77. For timers I keep the id from `setInterval` and call `clearInterval(id)` in the same cleanup. If I forget, the emitter or timer still holds my handler, the handler's closure holds the screen's props, state setters and whatever data they reference, so none of it can be garbage-collected; each remount adds another live handler, and the interval keeps polling the network after the screen is gone.

code

tsx · 27 lines
tsx
import {useEffect, useState} from 'react';
import {AppState, Text} from 'react-native';

export function FareTicker({fetchFares}: {fetchFares: () => Promise<number>}) {
  const [fare, setFare] = useState<number | null>(null);

  useEffect(() => {
    let active = AppState.currentState === 'active';

    const subscription = AppState.addEventListener('change', next => {
      active = next === 'active';
    });

    const id = setInterval(() => {
      if (active) {
        fetchFares().then(setFare);
      }
    }, 30000);

    return () => {
      subscription.remove();
      clearInterval(id);
    };
  }, [fetchFares]);

  return <Text>{fare === null ? 'Loading fares' : `From ${fare}`}</Text>;
}

go deeper

for a junior

Remember the shape: React Native's add calls return a subscription, you call remove() on it, and you clearInterval the saved id, all inside the useEffect cleanup.

for a middle

Explain why a forgotten handler leaks: the emitter holds the closure, the closure holds state and data, and each remount adds another live handler.

for a senior

Show how you would detect stacked handlers in a running app, and how you separate unmount cleanup from pausing work on unfocused but still-mounted screens.

for a principal

Discuss team-level guardrails: shared hooks that own subscriptions, review checklists for effects that subscribe, and leak checks in repeated-navigation tests.

## The pattern React Native's APIs expect Most React Native modules that emit events follow one shape: **subscribing returns an object whose `remove()` method unsubscribes**. | API | Subscribe | Unsubscribe | |---|---|---| | `AppState` | `AppState.addEventListener('change', fn)` | `subscription.remove()` | | `Keyboard` | `Keyboard.addListener('keyboardDidShow', fn)` | `subscription.remove()` | | `Dimensions` | `Dimensions.addEventListener('change', fn)` | `subscription.remove()` | | `Linking` | `Linking.addEventListener('url', fn)` | `subscription.remove()` | | `BackHandler` (Android) | `BackHandler.addEventListener('hardwareBackPress', fn)` | `subscription.remove()` | | timers | `const id = setInterval(fn, ms)` | `clearInterval(id)` | The older `removeEventListener(type, handler)` methods on these modules were removed; `BackHandler.removeEventListener` was one of the last, gone in 0.77. Code that still calls them fails at run time, so the subscription object is the only way to unsubscribe. ## Where the cleanup goes In a function component the subscription is created in `useEffect` and removed in the function the effect returns. React runs that cleanup before the effect re-runs and when the component unmounts: 1. **Subscribe** inside the effect and keep the returned object in a local variable. 2. **Start timers** in the same effect and keep their ids. 3. **Return a cleanup** that calls `remove()` on every subscription and `clearInterval` / `clearTimeout` on every id. 4. **List the right dependencies**, so a changed handler input re-subscribes cleanly instead of stacking a second listener. ## What leaks when you forget Take a travel feed screen that refreshes fares every 30 seconds and pauses when the app goes to the background. - **The handler stays registered.** `AppState`'s emitter keeps a reference to the handler for as long as the subscription exists. - **The closure keeps the screen alive.** The handler is a **closure**: it references the state setter, props and any variables it uses — for example the array of feed items with their image URIs. Everything reachable from it stays reachable, so the garbage collector cannot reclaim it. - **Remounts multiply the damage.** Every time the user leaves and re-opens the feed, another handler and another interval are added; after ten visits there are ten intervals polling the network. - **Work continues after unmount.** The interval keeps firing, fetching fares and calling setters for a screen nobody can see, costing battery, data and JavaScript-thread time. ## Why garbage collection does not save you Hermes, React Native's JavaScript engine, frees objects that are **unreachable** — nothing live refers to them any more. Unmounting a component does not make its data unreachable if something long-lived still points at it: - the native-backed emitter behind `AppState` lives for the whole app session, and so does its list of handlers; - the timer registry keeps every uncleared interval callback; - each callback keeps its closure, and the closure keeps what it references. So the leak is not a collector bug. It is a reference you created and never released, and only `remove()` or `clearInterval` releases it. ## Common variations of the same leak - **`setTimeout` chains** that reschedule themselves: clear the latest id on cleanup. - **`requestAnimationFrame` loops**: cancel with `cancelAnimationFrame`. - **Module-level emitters or stores** you subscribe to from a screen: they hold your callback exactly like `AppState` does, and need the same unsubscribe. - **Native module event emitters** built on `NativeEventEmitter` return subscriptions with `remove()` too. ## A note on stack navigators Leaving a screen does not always unmount it. In a stack navigator, the previous screen stays mounted underneath the new one, so its effects — and its intervals — keep running until it is popped. Cleanup on unmount prevents leaks; pausing work while a screen is not focused is a separate concern handled with the navigator's focus events. ## How to catch it - Add a log or counter inside the handler, open and close the screen several times, and trigger the event: one call per event is correct, several means handlers are stacking. - Watch JavaScript heap growth across repeated visits, then compare heap snapshots to find the retaining handler. - Lint rules for hook dependencies help keep re-subscriptions correct, but they do not check that you called `remove()`. - Wrap recurring subscriptions in small custom hooks — one hook that subscribes to `AppState` and returns the current state, for example — so the `remove()` call is written once and every screen gets it for free. - In code review, treat any effect that calls an `add…` method or starts a timer without returning a cleanup as a bug until proven otherwise.

  • Your React Native code calls AppState.removeEventListener('change', handler). What happens on 0.87, and what is the fix?
    That method no longer exists on `AppState`, so the call fails at run time instead of unsubscribing. Keep the `EventSubscription` returned by `AppState.addEventListener` and call `subscription.remove()` in the effect cleanup; the same applies to `Keyboard`, `Dimensions`, `Linking` and `BackHandler`.
  • How can you tell from the app's behaviour that React Native listeners are stacking up?
    Log or count inside the handler, open and close the screen several times, then trigger the event. If one app-state change produces several log lines, or network requests multiply with each visit, handlers from earlier mounts are still registered. A JavaScript heap that grows with each visit points the same way.

saying these in an interview costs you the question

  • Call AppState.removeEventListener with the same handler to unsubscribe
  • Listeners are removed automatically when the component unmounts
  • A forgotten interval only wastes CPU, it cannot retain memory
  • Garbage collection frees the handler once the screen is gone
  • Leaving a stack screen always unmounts it and runs its cleanup
open as a page

Why can console.log calls slow a React Native release build, and how do you strip them with babel-plugin-transform-remove-console?

level: juniorimportance: must knowfreq 58%

basics

~20 s

React Native keeps console calls in release bundles, formatting every argument on the JS thread and passing it to the native logger. Add babel-plugin-transform-remove-console under env.production in babel.config.js to strip every console call from release builds.

open as a page

In React Native's Perf Monitor overlay, what do the separate JS and UI frame rates tell you, and how do you tell which thread janked?

level: middleimportance: must knowfreq 62%

basics

~20 s

The UI rate measures the native main thread that draws views and runs scrolling; the JS rate measures the thread running React and your handlers. Whichever falls, and which symptoms appear, tells you where to look.

open as a page

Why do performance numbers from a React Native debug build mislead you, and which build should you profile instead?

level: middleimportance: must knowfreq 62%

basics

~20 s

A debug build runs development-mode JavaScript served by Metro, with dev-only checks and warnings and no Hermes bytecode precompilation, so the JS thread does far more work than in production. Profile a release or profileable release-like build on a real device.

open as a page

In React Native, why do eager screen imports and module side effects lengthen cold start, and how do lazy-loaded screens help?

level: middleimportance: must knowfreq 52%

basics

~20 s

Every module imported from the entry file is evaluated before the first screen renders, including any top-level work it does. Loading rarely used screens lazily, with getComponent or React.lazy, keeps that work off the startup path.

open as a page

A React Native image-heavy travel feed crashes after about ten minutes of scrolling on low-end Android phones; how do you find and fix the cause?

level: seniorimportance: must knowfreq 48%

basics

~20 s

Reproduce it in a release build on a low-end phone, confirm from the crash log that memory ran out, then see which memory grows: native memory points to oversized image decodes, a growing JavaScript heap to leaked listeners, timers or closures.

open as a page

In a React Native Android project, what does the reactNativeArchitectures Gradle property control, and why build one ABI during development?

level: juniorimportance: should knowfreq 35%

basics

~20 s

reactNativeArchitectures lists the Android ABIs whose native code gets compiled and packaged, by default all four. Building only the ABI of your emulator or phone during development cuts native build time sharply; release builds must keep every ABI.

open as a page

In a React Native app, how much time does one frame get at 60 Hz and at 120 Hz, and what does dropping a frame mean?

level: juniorimportance: should knowfreq 50%

basics

~20 s

At 60 Hz each frame has about 16.7 ms and at 120 Hz about 8.3 ms. A dropped frame is one not ready by its deadline, so the screen shows the previous image again and motion stutters.

open as a page

Why is a React Native development build a poor place to judge jank, and what should you measure on instead?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Development mode makes the JS thread do much more work - warnings, validation, error messages and dev tooling - so frame drops appear that users never see. Judge jank on a release build running on a real device.

open as a page

In React Native, which tools capture native-side performance traces on iOS and on Android, and why doesn't React Native DevTools cover that?

level: juniorimportance: should knowfreq 42%

basics

~20 s

Xcode Instruments (usually the Time Profiler) on iOS and the Android Studio Profiler's system trace, viewable in Perfetto, on Android. React Native DevTools covers JavaScript and React only, and it is disabled in release builds.

open as a page

In an Expo app, what does expo-splash-screen's preventAutoHideAsync() do, where should you call it, and how can it make startup feel slower?

level: juniorimportance: should knowfreq 42%

basics

~10 s

preventAutoHideAsync() keeps the native splash screen up until you call SplashScreen.hide(). Call it at module top level, hide as soon as the first screen can render, and never make it wait on slow work.

open as a page

How do you enable ccache for a React Native app on Android and on iOS, and how do you confirm it is actually working?

level: middleimportance: should knowfreq 28%

basics

~20 s

Install ccache; React Native's Android CMake setup uses it automatically, and on iOS you turn on ccache_enabled in the Podfile's react_native_post_install (or run pod install with USE_CCACHE=1). Confirm with ccache -s: a second clean build should show mostly hits.

open as a page

How do React Native's precompiled iOS binaries speed up builds since 0.84, and when would you set RCT_USE_PREBUILT_RNCORE=0?

level: middleimportance: should knowfreq 32%

basics

~20 s

Since 0.84, pod install downloads React Native core and its dependencies as precompiled xcframeworks, so clean iOS builds skip compiling them. Set RCT_USE_PREBUILT_RNCORE=0 to build core from source, for example to patch it or opt out of Hermes V1.

open as a page

Why does a 2 MB JPEG in a React Native Image need more memory than its file size, and why do feeds suffer?

level: middleimportance: should knowfreq 42%

basics

~20 s

An Image is decoded into an uncompressed bitmap of roughly width x height x 4 bytes, so a 4000x3000 photo needs about 48 MB whatever its file size. A feed holding several at once exhausts memory, especially on low-end Android.

open as a page

In React Native 0.87, how do performance.mark, performance.measure and PerformanceObserver let you time a screen in a release build?

level: middleimportance: should knowfreq 24%

basics

~20 s

React Native implements the Web User Timing and Performance Timeline APIs, stable since 0.83: performance.mark records named instants, performance.measure records the span between them, and PerformanceObserver delivers those entries at runtime, including in production builds.

open as a page

With React Navigation's native stack in React Native, how do you keep a heavy screen from mounting during its push transition?

level: middleimportance: should knowfreq 40%

basics

~10 s

Render a light shell first, then mount the expensive subtree after the native stack's transitionEnd event fires with data.closing false, subscribing in an effect and returning the unsubscribe function.

open as a page

In a React Native FlatList, why do an inline renderItem arrow and inline style objects in each row make every parent re-render expensive?

level: middleimportance: should knowfreq 52%

basics

~20 s

FlatList is a PureComponent, so a new renderItem function on each render makes it re-render every mounted cell. Fresh style objects and arrow handlers then give each row new props, so memoized rows cannot skip their work.

open as a page

A two-platform React Native social app's CI build takes 25 minutes; which React Native build settings would you change to cut it, and in what order?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Measure which platform and phase dominate first. Then make sure iOS uses the prebuilt core, split Android test builds by ABI with reactNativeArchitectures, enable Gradle's configuration cache, point downloads at a Maven mirror, and add ccache or sccache where native compilation remains.

open as a page

In React Native 0.87, pushing a product-detail screen stutters because its mount effect builds a large variant index and fires analytics; with InteractionManager gone, how do you move that work out of the way?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Render a light screen first, then schedule non-urgent work with requestIdleCallback in small slices that check deadline.timeRemaining(), cancelling on unmount. It runs at idle priority, yielding to input and React updates, but does not wait for animations.

open as a page

In React Native, how do you compare Hermes heap snapshots to prove that a screen leaks, and how do you find what retains it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Take a baseline snapshot after warming up, open and close the screen a fixed number of times, take another, and compare: objects whose count grows with the repetitions are leaking. Their retainer path shows which listener, timer, cache or closure holds them.

open as a page

A React Native live sports-scores screen stutters on Android as scores stream in; how do you use an Android Studio system trace to find which thread is at fault?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Capture a system trace from a profileable build on a real device, then compare threads against frame boundaries: the JavaScript thread (mqt_v_js) running across frames means a JavaScript problem; the UI thread or RenderThread overrunning means native view or GPU work.

open as a page

In React Native, a Hermes sampling profile recorded in a debug build blames a score-formatting helper for scores-screen jank; how do you read it and confirm the finding?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A Hermes sampling profile periodically records the JavaScript call stack, so it shows each function's share of JS-thread time, not exact durations or native work. Check the helper's self time, discount dev-mode frames, then confirm with performance.measure in a release build.

open as a page

A React Native supermarket app takes 4 s from tapping its icon to a usable home screen on mid-range Android; how would you find and cut the biggest costs?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Measure cold starts on a release build on a mid-range Android phone, split the time into phases, then cut the largest: eager imports, import-time side effects, bundle bloat and network waits before the home screen.

open as a page

How would you set and enforce a cold-start budget for a React Native app team so startup time stops creeping up release after release?

level: principalimportance: should knowfreq 25%

basics

~20 s

Define cold start to interactive precisely, measure it on release builds on a reference mid-range Android device, set per-platform budgets with phase breakdowns, and guard the usual React Native regressions: entry-file imports, import-time work, bundle growth and network waits.

open as a page

Since React Native 0.79, why is the Android JavaScript bundle stored uncompressed in the APK, and what does enableBundleCompression trade off?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

An uncompressed bundle can be memory-mapped straight from the APK instead of being decompressed before the app starts, which shortens Android startup. Setting enableBundleCompression to true shrinks the installed app but brings that startup cost back.

open as a page