Steven's Knowledge

Performance

React Native performance — render scope, list virtualization, image cache, startup time, JS vs UI thread

Performance

Performance work on React Native is mostly about three things: keep work off the JS thread, minimize re-renders, and respect device memory. Profile first, optimize the proven bottleneck, then re-profile.

The Two-Thread Model

┌─────────────┐   serialized   ┌─────────────┐
│  JS Thread  │ ←────────────→ │  UI Thread  │
│  (business) │                │  (render)   │
└─────────────┘                └─────────────┘
       ↓                              ↓
  setState / fetch              60 fps frame budget
  • Anything blocking the JS thread (heavy computation, big JSON parse, sync storage) stops state updates from being delivered.
  • Anything blocking the UI thread (long native-side work) drops frames even if JS is idle.
  • The bridge between them is serialized — every call has cost.

60 fps = 16.67 ms per frame. Profiling beyond this budget shows up as jank.

Profiling Toolchain

ToolWhat it shows
Flipper / React DevTools ProfilerComponent render times, why-did-you-render
Performance Monitor (in-app dev menu)FPS for JS thread and UI thread separately
Hermes Sampling ProfilerFunction-level CPU profile, exportable to Chrome devtools
Xcode InstrumentsiOS native side — Time Profiler, Allocations
Android Studio ProfilerAndroid native side — CPU, memory, energy
react-native-performanceLCP, TTI, render timings as metrics

Recording a Hermes Profile

# 1. Open dev menu → "Enable Sampling Profiler"
# 2. Reproduce the slow path
# 3. Dev menu → "Disable Sampling Profiler" (saves to device)
# 4. Pull and convert
adb pull /sdcard/sampling-profiler-trace1.cpuprofile
# Open in chrome://inspect → Performance → Load profile

Render Scope

The single highest-impact lever on RN performance is reducing how much of the tree re-renders.

Identify Wasted Renders

Use the React DevTools profiler. Cells that re-render with identical props are the targets.

Memo Boundaries

// Bad: row component re-renders on every parent state change
function Row({ item, onPress }: Props) {
  return <Pressable onPress={() => onPress(item.id)}>...</Pressable>;
}

// Good: memoize the component AND keep its props stable
const Row = memo(function Row({ item, onPress }: Props) {
  return <Pressable onPress={onPress}>...</Pressable>;
});

// In the parent
const handlePress = useCallback((id: string) => { /* ... */ }, []);
const renderItem = useCallback(({ item }) => (
  <Row item={item} onPress={handlePress} />
), [handlePress]);

Selectors Beat Context

// Bad: any state change re-renders everyone using the store hook
const { user, theme, cart } = useStore();

// Good: selector with shallow compare
const user = useStore((s) => s.user);

Zustand, Redux Toolkit, and Jotai all support this. Plain React Context does not — use one Context per concern instead.

List Virtualization

FlatList Tuning Cheat Sheet

PropPurposeRecommended
keyExtractorStable identity per rowAlways
getItemLayoutSkip measurement for fixed heightsWhen rows are fixed height
initialNumToRenderFirst batch sizeRoughly one screen worth
maxToRenderPerBatchBatch size per scroll tick5–10
windowSizeHow many screens around viewport to keep mounted5–10
removeClippedSubviewsDetach offscreen views (Android)true for long vertical lists only
updateCellsBatchingPeriodDebounce render batches50ms default is usually fine

FlashList for Heterogeneous Lists

import { FlashList } from '@shopify/flash-list';

<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={64}
  getItemType={(item) => item.kind}  // recycle by type
/>

estimatedItemSize is required and dramatic — a wildly wrong estimate causes visible glitches as cells re-layout.

Image Performance

The Three Costs

  1. Network — download time, dominates on cellular.
  2. Decode — JPEG/PNG → bitmap in memory. CPU-bound; happens on a worker thread but blocks display.
  3. Memory — decoded bitmap = width × height × 4 bytes. A 4000×3000 photo is 48 MB.

Strategy

// 1. Resize at the CDN (Cloudinary, imgix, custom)
const src = `${cdn}/photo.jpg?w=400&q=80`;

// 2. Use expo-image: disk cache + memory cache + transitions
import { Image } from 'expo-image';

<Image
  source={src}
  contentFit="cover"
  transition={150}
  placeholder={blurhash}
  cachePolicy="memory-disk"
  style={{ width: 100, height: 100 }}
/>

Preload Strategy

For hero images on the next screen:

import { Image } from 'expo-image';
Image.prefetch([url1, url2]);

Startup Time

Phases (measure each independently)

Native init ── JS bundle parse ── React mount ── First fetch ── First paint
   ~150ms          ~300ms              ~100ms        ~variable        ~50ms

Levers

PhaseAction
Native initLazy-load native modules; defer Sentry/analytics SDK init until after first paint
Bundle parseHermes ON; enable inline requires; split into RAM bundle on Android
React mountRender a splash placeholder synchronously, defer real work to InteractionManager
First fetchUse cached data (TanStack Query staleTime); prefetch during splash

Inline Requires

// metro.config.js
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,  // huge win on cold start
      },
    }),
  },
};

Animations: Reanimated v3

The rule: animation values live on the UI thread.

import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

const scale = useSharedValue(1);

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: scale.value }],
}));

return (
  <Pressable
    onPressIn={() => { scale.value = withSpring(0.95); }}
    onPressOut={() => { scale.value = withSpring(1); }}
  >
    <Animated.View style={animatedStyle}>...</Animated.View>
  </Pressable>
);

The useAnimatedStyle callback runs as a worklet on the UI thread. No bridge cost per frame.

InteractionManager

Run heavy non-urgent work after animations finish.

InteractionManager.runAfterInteractions(() => {
  // expensive parse / analytics flush / preload
});

Use sparingly — overusing it just defers jank rather than eliminating it. Better to make the work itself cheaper.

Memory Profiling

SymptomInvestigate with
App killed in backgroundXcode Memory Graph / Android Memory Profiler
Slow scroll on long screensList virtualization config
Sudden spikeImage decoding (search for <Image> without resized sources)
Slow leakRetained subscriptions, set-and-forget timers

Hermes Heap Snapshot

import { startSamplingHeapProfiler, stopSamplingHeapProfiler } from 'react-native';
// dev menu → Take Heap Snapshot → analyze in Chrome devtools

Network

Connection Reuse

Default fetch on RN uses platform HTTP stacks (Cronet on Android via flag, NSURLSession on iOS). Persistent connections are reused automatically — don't fight it with custom socket code.

Concurrent Requests

// Bad: serial
const user = await getUser();
const orders = await getOrders();

// Good: parallel
const [user, orders] = await Promise.all([getUser(), getOrders()]);

Deduplication

TanStack Query dedupes identical concurrent requests for free. If multiple components query the same endpoint, you only pay once.

Benchmarks To Aim For

MetricTargetCritical
Cold start TTI< 2.5s< 4s
Scroll FPS (1000-row list)60 fps> 55 fps
Tap → screen transition< 300ms< 500ms
Image LCP< 1.5s< 2.5s
JS thread blocked frames< 1%< 3%

Capture these from production with react-native-performance and Sentry performance monitoring; otherwise you optimize what you remember, not what users feel.

On this page