React Native performance profiling — finding the real bottleneck
← Back
April 4, 2026React Native8 min read

React Native performance profiling — finding the real bottleneck

Published April 4, 20268 min read

React Native performance problems hide well. The app feels sluggish but the JS frame rate looks fine. Lists scroll smoothly on your device but choppily on a mid-range Android. The Xcode profiler shows CPU spikes but you cannot map them back to your components. Getting to the real bottleneck requires using the right tools in the right order. Here is the sequence I follow.

Step 1: Establish a baseline with the frame rate overlay

Before profiling, enable the frame rate overlay to confirm you actually have a problem and identify which thread is struggling:

typescript
// In your app entry point (dev builds only)
import { PerformanceObserver } from 'react-native';

if (__DEV__) {
  // Enable performance monitor overlay
  // Shake device → Performance Monitor → enabled
  // Or programmatically:
  const { NativeModules } = require('react-native');
  NativeModules.PerfMonitor?.start();
}

The overlay shows two numbers: JS FPS and UI FPS. If UI FPS drops but JS FPS stays high, the problem is on the native side (too many view updates, GPU overdraw). If JS FPS drops, the problem is in your JavaScript (heavy renders, synchronous operations).

Step 2: React DevTools profiler for component render cost

Open Flipper → React DevTools → Profiler. Record a 10-second session of normal app usage including the sluggish interaction. The flame chart shows:

  • Which components rendered on each frame
  • How long each render took
  • Whether a render was "committed" (actually caused a DOM change) or "deferred"

The five issues I find in almost every app I audit:

Issue 1: Missing list key optimization

typescript
// BAD: re-renders every item on list data change
<FlatList
  data={items}
  renderItem={({ item }) => <ItemCard item={item} />}
  keyExtractor={(item) => item.id}
/>

// GOOD: memoize renderItem to prevent unnecessary re-renders
const renderItem = useCallback(
  ({ item }: { item: Item }) => <ItemCard item={item} />,
  []
);

<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  removeClippedSubviews={true}
  maxToRenderPerBatch={10}
  windowSize={5}
/>

Issue 2: Inline object/function props breaking memo

typescript
// BAD: new object on every parent render = memo never works
<ItemCard
  style={{ marginBottom: 8 }}    // new object every render
  onPress={() => handlePress(item.id)}  // new function every render
/>

// GOOD: stable references
const cardStyle = useMemo(() => ({ marginBottom: 8 }), []);
const handlePress = useCallback((id: string) => { /* ... */ }, []);

<ItemCard
  style={cardStyle}
  onPress={() => handlePress(item.id)}
/>

Issue 3: Context triggering excessive renders

typescript
// BAD: all consumers re-render when ANY part of context changes
const AppContext = createContext({ user, theme, notifications, settings });

// GOOD: split into granular contexts
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
// Components only subscribe to what they need

Issue 4: Image loading on the main thread

typescript
// BAD: unoptimized images block the UI thread
<Image source={{ uri: imageUrl }} />

// GOOD: use expo-image (faster, better caching, blurhash placeholder)
import { Image } from 'expo-image';

<Image
  source={imageUrl}
  placeholder={item.blurhash}
  contentFit="cover"
  transition={200}
  cachePolicy="memory-disk"
/>

Issue 5: JavaScript executing during scroll

typescript
// BAD: onScroll fires JS code every frame during scroll
<ScrollView
  onScroll={(event) => {
    setScrollY(event.nativeEvent.contentOffset.y); // JS setState at 60fps
  }}
  scrollEventThrottle={16}
/>

// GOOD: use Reanimated's useAnimatedScrollHandler (runs on UI thread)
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated';

const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
  onScroll: (event) => {
    scrollY.value = event.contentOffset.y; // UI thread only, no JS
  },
});

<Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16} />

Step 3: Hermes profiler for JS execution cost

When DevTools profiler shows expensive JS but you cannot isolate which code:

  1. Enable Hermes in app.json: "jsEngine": "hermes"
  2. Open React Native DevTools → Performance tab
  3. Record while triggering the slow interaction
  4. Look for long tasks (orange bars) — these are JS blocking the main thread

Common culprits found via Hermes profiler:

  • Parsing large JSON responses synchronously
  • Sorting or filtering large arrays on every render instead of memoizing
  • Third-party libraries doing heavy initialisation on import

The profiling order

  1. Frame rate overlay → identify which thread is slow
  2. React DevTools Profiler → find expensive component renders
  3. Hermes Profiler → find expensive JS operations
  4. Flipper Network plugin → find slow API calls that block perceived performance
  5. Flipper Layout inspector → find deep view hierarchies causing GPU overdraw

In my experience, 80% of React Native performance problems are fixed by the five issues listed above — mostly unnecessary re-renders and unoptimised lists. Profile first, optimise second. Guessing wastes a day.

Share this
← All Posts8 min read