Improving React Native App Performance

I walk through FlatList vs ScrollView for long lists, useCallback and React.memo for list items, InteractionManager for deferring heavy work after animations, the Hermes engine, and how to profile with React Native DevTools.

Improving React Native App Performance

Mobile users are less forgiving than web users. A 300ms delay on the web feels a little slow. On a phone, 300ms feels broken. The interface is supposed to feel like glass — direct, immediate, physical. When it doesn’t, people notice in a way they rarely do with a slow website.

The problems are predictable. Long lists that stutter when you scroll. Screens that freeze for half a second during a navigation transition. An app that takes four seconds to get interactive on a mid-range device. Each one has a specific cause and a specific fix — but none of them are obvious until you know what React Native is doing under the hood.

Quick answer: For long lists, use FlatList — not ScrollView — and provide getItemLayout if items have a fixed height. Wrap renderItem in useCallback, wrap item components in React.memo. Use InteractionManager.runAfterInteractions to defer heavy work until navigation animations finish. Always profile in a release build — dev mode overhead masks the real numbers.

Series: Part 8 of 8 — React Native for React Developers. Start with Part 1: React Native for React Developers · Previous in series: State Management in React Native Apps

On this page

FlatList vs ScrollView: picking the right component for long lists

If your list can have more than about twenty items, you should not be using ScrollView. It renders all its children at once — every item that exists in the data is mounted, measured, and kept in memory, regardless of whether it’s visible on screen.

FlatList works differently. It virtualizes the list using a windowing algorithm: only items near the current scroll position are mounted and rendered. Items that scroll far enough off screen are unmounted. The React Native documentation calls the rendered region the “window,” and its default size is 21 viewport heights — 10 above and 10 below the current view.

For a list of ten static items, ScrollView is simpler and performs fine. For anything that could grow — notifications, messages, a product catalogue, search results — start with FlatList. Refactoring from ScrollView to FlatList later is more painful than the slightly higher initial setup cost.

Getting FlatList scroll performance right

FlatList is already faster than ScrollView for long lists, but its out-of-the-box configuration is conservative. A few props make a concrete difference.

getItemLayout is the most important one. Without it, FlatList has to measure each item’s height asynchronously before it can calculate scroll positions. That async measurement introduces latency and causes blank areas during fast scrolls. If your list items have a fixed height, provide getItemLayout and cut that overhead entirely:

const ITEM_HEIGHT = 72;

<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  getItemLayout={(_data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
  renderItem={renderItem}
/>

initialNumToRender (default: 10) sets how many items mount in the first render. Set it to just enough to fill the first visible screen. For a 72px item on an 800px screen, 12 is about right. Going higher mounts invisible extras and slows the first render.

windowSize (default: 21) controls the total render window as a multiple of viewport height. Reducing it to 11 (5 above, 5 below the current view) uses less memory. The trade-off is a slightly higher chance of visible blank areas during fast scrolls on slow devices.

removeClippedSubviews (default true on Android) detaches off-screen views from the native view hierarchy. It doesn’t free memory — the views are detached, not deallocated — but it reduces the work the main thread does during scrolling.

Images in list items are a frequent source of jank. Displaying a full-resolution image inside a 72px list row forces the device to decode and scale it on every render. Use a cropped thumbnail at the actual display size, and keep the image component wrapped in React.memo like every other item child. Avoid loading full-size images in a list that scrolls; save those for the detail screen.

Check this before moving on

  • Your list uses FlatList, not ScrollView
  • You’ve set keyExtractor to a stable, unique ID — not the array index
  • If items have a fixed height, you’ve provided getItemLayout
  • initialNumToRender is set to approximately one screen’s worth of items

useCallback, useMemo, and React.memo for list items

Every time a parent component re-renders, a new function literal is created for renderItem. If that new reference differs from the previous one, FlatList re-renders every visible item — even if the underlying data hasn’t changed.

The fix is useCallback:

const renderItem = useCallback(
  ({ item }: { item: Product }) => (
    <ProductCard product={item} onPress={handlePress} />
  ),
  [handlePress]
);

Pair that with React.memo on the item component itself:

import { memo } from 'react';

const ProductCard = memo(function ProductCard({
  product,
  onPress,
}: {
  product: Product;
  onPress: (id: string) => void;
}) {
  return (
    <View>
      {/* card content */}
    </View>
  );
});

With both in place, FlatList only re-renders a ProductCard when its product or onPress prop actually changes — not on every scroll tick or parent state update.

If handlePress is defined in the parent, wrap that in useCallback too. Otherwise, every parent render creates a new function reference, which invalidates the renderItem dependency, which re-renders every visible card anyway.

The same useCallback and useMemo logic from web React applies here — the React performance guide on this blog covers when memoisation helps and when it adds unnecessary overhead. React Native doesn’t change the math; it just means the stakes are slightly higher because mobile CPUs run slower than laptop CPUs.

InteractionManager: don’t block animations with data loading

Here is a common React Native problem: you navigate to a screen that loads data immediately in a useEffect. The screen slides in — or tries to. The data fetch keeps the JS thread busy during the animation, and the transition stutters or freezes halfway through.

InteractionManager solves this. It lets you defer work until after the current interaction — the navigation animation, a gesture, a state transition — has finished. The React Native API provides a single method for this:

import { InteractionManager } from 'react-native';
import { useEffect, useState } from 'react';

function ProductScreen() {
  const [data, setData] = useState<Product[] | null>(null);

  useEffect(() => {
    const task = InteractionManager.runAfterInteractions(async () => {
      const result = await fetchProducts();
      setData(result);
    });

    return () => task.cancel();
  }, []);

  if (!data) {
    return <LoadingSpinner />;
  }

  return <ProductList data={data} />;
}

Navigation animations run on the native thread and don’t strictly require JavaScript. But heavy JS work that starts immediately on mount still competes for CPU during the animation frame budget. InteractionManager gives the animation space to complete first, then executes your callback. Always cancel the returned handle in the cleanup function — if the component unmounts before the interaction ends, you don’t want deferred work firing against an unmounted component.

Hermes: the JS engine React Native uses by default

Since React Native 0.70, Hermes is the default JavaScript engine on both iOS and Android. It replaced JavaScriptCore for most projects, and the difference matters for startup performance.

JavaScriptCore compiles JavaScript at runtime, when the app starts. Hermes compiles JavaScript to bytecode at build time. When the app launches, it executes pre-compiled bytecode instead of parsing and compiling source on device. The result is faster time-to-interactive, lower memory usage, and a smaller JS bundle, according to the React Native documentation on Hermes.

You can verify Hermes is running with a simple check:

const isHermes = () => !!global.HermesInternal;
console.log('Running on Hermes:', isHermes());

If you created your project with a recent version of the React Native CLI or Expo SDK, Hermes is already enabled. For older projects, the documentation has platform-specific steps to opt in. One thing to keep in mind: Hermes’s startup benefits only appear in release builds. In development mode, the bytecode optimization is disabled to support hot reloading — which is partly why profiling in dev mode is misleading.

Profiling with React Native DevTools

React Native DevTools is built on Chrome DevTools and ships with React Native. Access it from the in-app developer menu while your app runs in development mode.

Two panels are most useful for performance work:

React Profiler records the timing of component renders and commits. You’ll see which components rendered, how long each took, and why — whether it was a prop change, state change, or context update. Enable “Highlight updates when components render” in the Components panel settings. Components that re-render unnecessarily flash visually on the device. A fast-flashing list item is almost always a missing React.memo or an unstable prop.

Performance panel (available since React Native 0.83) records a JavaScript execution trace. You can see which functions ran, how long they took, and which calls blocked a frame. Network requests appear in the same timeline. This is where you find expensive computations that should either be memoized or deferred.

Try this

  1. Open the developer menu in your app (shake the device, or press Cmd+D on iOS Simulator / Cmd+M on Android Emulator).
  2. Select “Open DevTools.”
  3. Go to the Components panel, open View Settings, and enable “Highlight updates when components render.”
  4. Scroll your main list slowly.

Expected result: Items that re-render unnecessarily flash yellow. If every visible card flashes on every scroll event, renderItem is recreating on every render — wrap it in useCallback.

The mistake that makes every measurement wrong

Every performance profile you run in development mode is unreliable for production decisions.

React Native adds extra validation, extra warnings, and disables Hermes bytecode optimization in dev mode. The JS thread runs measurably slower. A screen that takes 200ms to render in dev might take 60ms in a release build. The inverse also happens — a component that looks fine in dev causes frame drops in release because the debug overhead was covering a real problem.

Always test performance in a release build:

# iOS release build
npx react-native run-ios --mode Release

# Android release build
npx react-native run-android --variant=release

The React Native documentation is explicit about this: running in development mode causes significant JavaScript thread overhead that cannot be avoided.

What you’re investigating Where to measure Why
Screen is slow to become interactive Release build, real device Dev mode and emulators mask real CPU behavior
FlatList scrolling is choppy Release build Virtualization, memo, and dev overhead all interact
Startup takes too long Release build Hermes bytecode only applies in release
Specific component re-renders too often Dev mode, React Profiler The render flamegraph is only available in dev
A mobile device outline containing six vertical bars that increase in height from left to right, connected by an upward trend line, representing progressive performance improvements in a mobile app.

Performance improves incrementally — each technique builds on the last, and the gains compound.

Eight parts in — what actually changes

This series started from one observation: React Native reuses the React programming model but changes the render target. Every decision that followed — navigation, state persistence, performance — came from that single difference.

The techniques here aren’t steps to memorize. They’re tools you reach for when something specific goes wrong. FlatList with getItemLayout when a list scrolls poorly. InteractionManager when a navigation transition feels sluggish. The React Profiler when you suspect unnecessary re-renders but can’t pin down which component. Hermes is already on — it’s doing its job.

The most useful habit this whole series points toward is the same one it started with: look at what’s actually happening before assuming you know the problem. The performance issues easiest to guess are almost never the ones that matter.

If you want to go deeper, the React Native architecture docs cover JSI, Fabric, and TurboModules — the new architecture that replaces the bridge model. The hooks guide on this blog is worth revisiting with fresh eyes now that you’ve seen useCallback and useMemo in a mobile context.

Build something real. That’s where all of this actually makes sense.

Sources