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
| Tool | What it shows |
|---|---|
| Flipper / React DevTools Profiler | Component render times, why-did-you-render |
| Performance Monitor (in-app dev menu) | FPS for JS thread and UI thread separately |
| Hermes Sampling Profiler | Function-level CPU profile, exportable to Chrome devtools |
| Xcode Instruments | iOS native side — Time Profiler, Allocations |
| Android Studio Profiler | Android native side — CPU, memory, energy |
| react-native-performance | LCP, 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 profileRender 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
| Prop | Purpose | Recommended |
|---|---|---|
keyExtractor | Stable identity per row | Always |
getItemLayout | Skip measurement for fixed heights | When rows are fixed height |
initialNumToRender | First batch size | Roughly one screen worth |
maxToRenderPerBatch | Batch size per scroll tick | 5–10 |
windowSize | How many screens around viewport to keep mounted | 5–10 |
removeClippedSubviews | Detach offscreen views (Android) | true for long vertical lists only |
updateCellsBatchingPeriod | Debounce render batches | 50ms 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
- Network — download time, dominates on cellular.
- Decode — JPEG/PNG → bitmap in memory. CPU-bound; happens on a worker thread but blocks display.
- 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 ~50msLevers
| Phase | Action |
|---|---|
| Native init | Lazy-load native modules; defer Sentry/analytics SDK init until after first paint |
| Bundle parse | Hermes ON; enable inline requires; split into RAM bundle on Android |
| React mount | Render a splash placeholder synchronously, defer real work to InteractionManager |
| First fetch | Use 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
| Symptom | Investigate with |
|---|---|
| App killed in background | Xcode Memory Graph / Android Memory Profiler |
| Slow scroll on long screens | List virtualization config |
| Sudden spike | Image decoding (search for <Image> without resized sources) |
| Slow leak | Retained subscriptions, set-and-forget timers |
Hermes Heap Snapshot
import { startSamplingHeapProfiler, stopSamplingHeapProfiler } from 'react-native';
// dev menu → Take Heap Snapshot → analyze in Chrome devtoolsNetwork
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
| Metric | Target | Critical |
|---|---|---|
| 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.