Skip to content
All articles
Mobile

React Native Performance Optimization: 10 Tips That Actually Move the Needle

January 8, 2026 8 min readBy Daniyal Alam
React Native Performance Optimization: 10 Tips That Actually Move the Needle — article by Daniyal Alam

React Native performance is the difference between an app users love and one they delete after the first lag spike. After shipping production apps at DanixSoft across e-commerce, fintech, and SaaS verticals, I have learned that most performance problems share the same root causes — and fixing them is methodical, not magical. Here are ten tips that have consistently moved the needle in real codebases.

1. Enable Hermes

If you are not running Hermes, you are leaving free performance on the table. Hermes is Meta's JavaScript engine optimized for React Native: it pre-compiles JS to bytecode at build time, reducing startup time and memory footprint significantly.

For React Native 0.70+, Hermes is enabled by default. If you are on an older project, enable it explicitly:

Android (android/app/build.gradle):

project.ext.react = [
  enableHermes: true
]

iOS (Podfile):

use_react_native!(
  :hermes_enabled => true
)

Run pod install and rebuild. You will notice faster cold starts and lower memory usage immediately. Hermes also unlocks the Hermes Profiler in Flipper, which is your best tool for diagnosing JS-thread bottlenecks.

2. Adopt the New Architecture (Fabric + TurboModules)

The New Architecture — Fabric for the renderer and TurboModules for native modules — eliminates the asynchronous JSON-serialized bridge that was React Native's original Achilles heel. With JSI (JavaScript Interface), JS can call native code synchronously, and native modules load lazily instead of all at startup.

As of React Native 0.76, the New Architecture is enabled by default in new projects. For existing apps, migrate incrementally: enable it in android/gradle.properties and ios/Podfile, audit your third-party libraries for New Architecture support, and replace any bridge-heavy custom modules with JSI-based TurboModules. The migration takes effort, but the payoff is lower latency on every native interaction.

3. Tune FlatList — or Switch to FlashList

List rendering is the most common performance bottleneck I see in production apps. The default ScrollView renders everything at once; FlatList uses windowing, but only if you configure it correctly.

Before (common mistakes):

<FlatList
  data={items}
  renderItem={({ item }) => <ItemCard item={item} />}
/>

After (tuned):

const keyExtractor = useCallback((item) => item.id.toString(), []);

const getItemLayout = useCallback(
  (_, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  }),
  []
);

const renderItem = useCallback(
  ({ item }) => <ItemCard item={item} />,
  []
);

<FlatList
  data={items}
  keyExtractor={keyExtractor}
  getItemLayout={getItemLayout}
  renderItem={renderItem}
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={5}
  removeClippedSubviews={true}
/>

getItemLayout eliminates the need to measure items dynamically — a huge win for long, uniform lists. removeClippedSubviews unmounts off-screen views from the native layer.

For very large or heterogeneous lists, drop in FlashList from Shopify. It reuses cell components aggressively and consistently outperforms FlatList in benchmarks. Swapping is nearly a one-to-one API replacement.

4. Memoize Aggressively — but Correctly

Memoization prevents unnecessary re-renders, but misuse creates bugs or wastes memory. The three tools and when to use them:

  • React.memo: Wrap pure components that receive the same props frequently. Always pair with stable prop references.
  • useCallback: Memoize functions passed as props or used in dependency arrays.
  • useMemo: Cache expensive computed values, not trivial ones.

The most common mistake I see is defining objects or functions inline inside render:

// Bad — new object reference every render, breaks React.memo on child
<MyComponent style={{ flex: 1, padding: 16 }} onPress={() => doSomething()} />

// Good — stable references
const styles = StyleSheet.create({ container: { flex: 1, padding: 16 } });
const handlePress = useCallback(() => doSomething(), []);

<MyComponent style={styles.container} onPress={handlePress} />

StyleSheet.create does more than organize styles — it registers them natively and sends a single ID over the bridge instead of a serialized object on every render.

5. Run Animations on the UI Thread

If your animations are driven by JS, any JS-thread freeze will cause jank. The fix is to keep animation logic entirely on the UI thread using the native driver or Reanimated.

For simple animations with the Animated API:

Animated.timing(opacity, {
  toValue: 1,
  duration: 300,
  useNativeDriver: true, // <-- this line matters
}).start();

For complex, gesture-driven, or physics-based animations, React Native Reanimated (v3+) is the right tool. Worklets run on the UI thread via JSI, so they are immune to JS-thread slowness. If your app has any meaningful animation — transitions, scroll-linked effects, drag gestures — Reanimated is worth the learning curve.

6. Optimize Images and Cache Aggressively

Unoptimized images are a silent performance killer. A few non-negotiable practices:

  • Serve images at the exact resolution they will be displayed, in WebP format. WebP is smaller than JPEG/PNG with comparable quality.
  • Use resizeMode and explicit width/height props so the layout engine does not have to calculate dimensions after the image loads.
  • Replace the default Image component with react-native-fast-image. It uses a persistent disk and memory cache (Glide on Android, SDWebImage on iOS) and respects cache-control headers. Cold loads become warm loads on the second visit.
import FastImage from 'react-native-fast-image';

<FastImage
  source={{
    uri: imageUrl,
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
  style={{ width: 200, height: 200 }}
  resizeMode={FastImage.resizeMode.cover}
/>

7. Reduce Bundle Size and Startup Time

Every kilobyte in your JS bundle adds to startup time. Audit your bundle regularly:

  • Use Metro's bundle visualizer or react-native-bundle-visualizer to identify large dependencies.
  • Prefer smaller alternatives: date-fns over moment, zustand or jotai over Redux when a lighter store fits your needs.
  • Enable inline requires in metro.config.js. This lazy-loads modules only when they are first called, rather than evaluating the entire bundle at startup.
// metro.config.js
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        inlineRequires: true,
      },
    }),
  },
};
  • Split navigation stacks so screens are not loaded until navigated to.
  • Tree-shake icon libraries — importing from react-native-vector-icons incorrectly pulls in all icon sets. Import from the specific set file.

8. Minimize Bridge Traffic

Even with the New Architecture's JSI, unnecessary data movement between JS and native is wasteful. Practical rules:

  • Batch native module calls. If you need to read multiple values from native, design one call that returns an object instead of five separate calls.
  • Avoid passing large data structures over the bridge. If you are storing megabytes of data in state that gets serialized on every navigation, move that data to a native SQLite layer (via op-sqlite or react-native-mmkv) and read it on demand.
  • MMKV is a drop-in replacement for AsyncStorage with synchronous reads and writes via JSI — a major win for frequent preference reads at app startup.

9. Offload Heavy Computation

JavaScript is single-threaded. A 200ms computation on the JS thread means 200ms of dropped frames. Options for offloading:

  • Web Workers via react-native-threads or Reanimated worklets: Good for self-contained CPU work like data transformation.
  • Native modules: Write a TurboModule in Kotlin/Swift for truly heavy lifting — image processing, cryptography, complex parsing.
  • Idle callbacks: If the work can be deferred, schedule it with InteractionManager.runAfterInteractions so it does not compete with animations and transitions.
import { InteractionManager } from 'react-native';

useEffect(() => {
  const task = InteractionManager.runAfterInteractions(() => {
    processHeavyData(rawData);
  });
  return () => task.cancel();
}, [rawData]);

10. Profile Before You Optimize

Everything above is general guidance. Your specific bottleneck requires measurement.

Tools I use in every investigation:

  • Flipper + React DevTools plugin: Visualize component re-renders. If a component lights up red on every frame, you have a memoization or state-shape problem.
  • React Native Performance Monitor: Enable via the dev menu. Watch JS FPS and UI FPS separately. If UI FPS drops but JS FPS is fine, the bottleneck is native rendering. The inverse means JS-thread overload.
  • Hermes Profiler: In Flipper, connect Hermes and record a CPU profile during the slow interaction. The flame graph will show you exactly which functions are consuming time.
  • Systrace / Android Profiler / Instruments (iOS): For native-side bottlenecks that Flipper cannot surface.

My workflow: reproduce the issue on a release build (dev mode adds overhead that masks real numbers), capture a Hermes profile, identify the top hotspot, fix it, measure again. Never optimize based on intuition alone.


Key Takeaways

  • Enable Hermes on every project — it is free startup and memory improvement.
  • Migrate to the New Architecture to eliminate bridge latency on native calls.
  • FlatList tuninggetItemLayout, keyExtractor, and stable renderItem — solves the majority of list jank; FlashList solves the rest.
  • Memoize at component boundaries and never create inline objects or functions in render.
  • Use useNativeDriver: true for Animated, and Reanimated for anything complex.
  • react-native-fast-image and WebP images eliminate most image-related slowness.
  • Inline requires + bundle analysis cut startup time without changing app logic.
  • MMKV replaces AsyncStorage with synchronous JSI reads — measure the difference on first render.
  • InteractionManager keeps heavy work away from animation frames.
  • Profile on a release build with the Hermes Profiler before committing to any optimization.

Performance work compounds. Fix the top bottleneck, measure, then fix the next. Ship incrementally and let real user metrics — not synthetic benchmarks — tell you when you are done.