- Why Performance Matters in React Native
- FlatList Optimization
- Memoization with useMemo and useCallback
- React.memo and Component Optimization
- Hermes JavaScript Engine
- Profiling with Flipper and React DevTools
- Image Optimization
- Bundle Size Reduction
- Additional Performance Tips
- Interview Questions & Answers
- Navigation
React Native runs JavaScript on a separate thread from the native UI thread. When the JS thread is blocked — by heavy computation, excessive re-renders, or large list rendering — users experience jank: dropped frames, delayed touch responses, and stuttering scroll.
Performance optimization in React Native targets three layers:
| Layer | What to Optimize | Common Tools |
|---|---|---|
| JS Thread | Re-renders, memoization, list virtualization | React DevTools, Hermes |
| UI Thread | Layout, animations, native modules | Flipper, Systrace |
| Bridge / JSI | Serialization overhead, native calls | New Architecture, TurboModules |
A 60 FPS target means each frame has 16.67 ms to complete. Missing this budget causes visible stutter.
FlatList virtualizes long lists by rendering only visible items plus a small buffer. Without proper configuration, it re-renders unnecessarily and measures items on every scroll frame.
import React, { useCallback, memo } from 'react';
import {
FlatList,
ListRenderItem,
StyleSheet,
Text,
View,
} from 'react-native';
interface Product {
id: string;
name: string;
price: number;
}
const ITEM_HEIGHT = 72;
// Memoized row component prevents re-renders when parent state changes
const ProductRow = memo(({ item }: { item: Product }) => (
<View style={styles.row}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.price}>${item.price.toFixed(2)}</Text>
</View>
));
export function ProductList({ products }: { products: Product[] }) {
const keyExtractor = useCallback((item: Product) => item.id, []);
const renderItem: ListRenderItem<Product> = useCallback(
({ item }) => <ProductRow item={item} />,
[],
);
const getItemLayout = useCallback(
(_: ArrayLike<Product> | null | undefined, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
return (
<FlatList
data={products}
keyExtractor={keyExtractor}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews={true}
updateCellsBatchingPeriod={50}
/>
);
}
const styles = StyleSheet.create({
row: {
height: ITEM_HEIGHT,
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
alignItems: 'center',
},
name: { fontSize: 16 },
price: { fontSize: 14, color: '#666' },
});| Prop / Technique | Purpose |
|---|---|
keyExtractor |
Stable unique keys prevent full list remounts |
getItemLayout |
Skips async layout measurement for fixed-height rows |
memo on row component |
Prevents row re-render when unrelated state changes |
windowSize |
Controls how many screen-heights of items stay mounted |
removeClippedSubviews |
Unmounts off-screen views (Android benefit is larger) |
For fewer than ~20 static items, a simple ScrollView with mapped children is fine. For infinitely scrolling feeds with variable heights, consider @shopify/flash-list which recycles views more aggressively.
useMemo caches computed values; useCallback caches function references. Both prevent child components wrapped in React.memo from re-rendering due to new object/function references on every parent render.
import React, { useCallback, useMemo, useState } from 'react';
import { Button, FlatList, Text, View } from 'react-native';
interface Order {
id: string;
total: number;
status: 'pending' | 'completed';
}
interface OrderListProps {
orders: Order[];
onOrderPress: (id: string) => void;
}
const OrderList = React.memo(({ orders, onOrderPress }: OrderListProps) => (
<FlatList
data={orders}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Button title={`Order ${item.id}`} onPress={() => onOrderPress(item.id)} />
)}
/>
));
export function OrdersScreen({ orders }: { orders: Order[] }) {
const [filter, setFilter] = useState<'all' | 'pending'>('all');
// useMemo: expensive filtering runs only when orders or filter changes
const filteredOrders = useMemo(
() =>
filter === 'all'
? orders
: orders.filter((o) => o.status === 'pending'),
[orders, filter],
);
// useCallback: stable reference so OrderList memoization works
const handleOrderPress = useCallback((id: string) => {
console.log('Selected order:', id);
}, []);
const totalRevenue = useMemo(
() => filteredOrders.reduce((sum, o) => sum + o.total, 0),
[filteredOrders],
);
return (
<View>
<Text>Revenue: ${totalRevenue.toFixed(2)}</Text>
<Button title="Show Pending" onPress={() => setFilter('pending')} />
<OrderList orders={filteredOrders} onOrderPress={handleOrderPress} />
</View>
);
}Use memoization when:
- Passing callbacks to memoized children
- Computing expensive derived data (sorting, filtering large arrays)
- Preventing unnecessary effect re-runs
Avoid over-memoization when:
- Values are cheap to compute
- Components always re-render anyway (parent not memoized)
- Premature optimization adds complexity without measurable gain
React.memo performs a shallow prop comparison before re-rendering. Combine it with stable props for maximum benefit.
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
interface AvatarProps {
name: string;
size: number;
isOnline: boolean;
}
// Custom comparison for complex props
const Avatar = React.memo(
({ name, size, isOnline }: AvatarProps) => (
<View style={[styles.avatar, { width: size, height: size }]}>
<Text>{name.charAt(0).toUpperCase()}</Text>
{isOnline && <View style={styles.onlineDot} />}
</View>
),
(prev, next) =>
prev.name === next.name &&
prev.size === next.size &&
prev.isOnline === next.isOnline,
);
const styles = StyleSheet.create({
avatar: {
borderRadius: 999,
backgroundColor: '#ddd',
alignItems: 'center',
justifyContent: 'center',
},
onlineDot: {
position: 'absolute',
bottom: 0,
right: 0,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#22c55e',
},
});
export default Avatar;Hermes is a JavaScript engine optimized for React Native on mobile. It improves startup time, memory usage, and TTI (Time to Interactive).
| Benefit | Explanation |
|---|---|
| Precompiled bytecode | No JIT warmup; faster cold start |
| Smaller memory footprint | Better on low-end Android devices |
| Built-in Intl support | Internationalization without polyfills |
Hermes is enabled by default in React Native 0.70+. Verify in android/gradle.properties:
hermesEnabled=trueFor iOS, check ios/Podfile:
use_react_native!(
:hermes_enabled => true,
)const isHermes = () => !!(global as typeof global & { HermesInternal?: unknown }).HermesInternal;
console.log('Hermes enabled:', isHermes());Use Chrome DevTools via Metro or Flipper for debugging. Hermes supports source maps for readable stack traces in production crash reports.
Flipper provides layout inspection, network monitoring, database plugins, and the React DevTools and Performance plugins.
# Install Flipper desktop app from https://fbflipper.com
# React Native 0.62+ includes Flipper integration by default
npx react-native start
# Run app on device/simulator — Flipper auto-detects- JS thread frame drops during scroll — optimize list rendering
- Long tasks on mount — defer non-critical work, use lazy loading
- Excessive bridge traffic — batch native calls, adopt New Architecture
- Memory growth — check for listener leaks, unbounded caches
// Wrap suspicious subtree to isolate in Profiler
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
) => {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms (${phase})`);
}
};
export function ProfiledScreen({ children }: { children: React.ReactNode }) {
return (
<Profiler id="HomeScreen" onRender={onRender}>
{children}
</Profiler>
);
}Use libraries like @shopify/react-native-performance or custom analytics to track screen load times and frame rates in release builds.
Images are a common source of memory pressure and slow loads.
import React from 'react';
import { Dimensions, Image, StyleSheet, View } from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const THUMB_SIZE = SCREEN_WIDTH / 3;
interface ThumbnailProps {
uri: string;
}
export function OptimizedThumbnail({ uri }: ThumbnailProps) {
return (
<View style={styles.container}>
<Image
source={{ uri }}
style={styles.image}
resizeMode="cover"
// Request appropriately sized image from CDN when possible
// e.g., uri + '?w=200&h=200'
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: THUMB_SIZE,
height: THUMB_SIZE,
},
image: {
width: '100%',
height: '100%',
},
});| Technique | Impact |
|---|---|
| Serve WebP/AVIF from CDN | Smaller downloads |
| Request correct dimensions | Avoid decoding 4K into 100px thumbnail |
Use react-native-fast-image |
Disk/memory caching, priority loading |
| Prefetch critical images | Image.prefetch(url) before navigation |
| Use local assets for icons | Bundled, no network latency |
| Lazy load off-screen images | Intersection-based loading in lists |
import FastImage from 'react-native-fast-image';
<FastImage
source={{
uri: 'https://cdn.example.com/photo.webp',
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
}}
style={{ width: 200, height: 200 }}
resizeMode={FastImage.resizeMode.cover}
/>Smaller JS bundles mean faster startup and less memory.
// 1. Lazy load screens with React.lazy (requires Suspense boundary)
import React, { Suspense, lazy } from 'react';
import { ActivityIndicator, View } from 'react-native';
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'));
export function AppNavigator() {
return (
<Suspense
fallback={
<View style={{ flex: 1, justifyContent: 'center' }}>
<ActivityIndicator />
</View>
}
>
<SettingsScreen />
</Suspense>
);
}// 2. metro.config.js — enable inline requires for deferred module loading
module.exports = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
};# Generate bundle and analyze
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output /tmp/android.bundle
# Use source-map-explorer or react-native-bundle-visualizer
npx react-native-bundle-visualizer- Remove unused dependencies (
depcheck,npm ls) - Replace heavy libraries (moment.js → date-fns or dayjs)
- Tree-shake lodash:
import debounce from 'lodash/debounce' - Split features by route with dynamic imports
- Enable ProGuard/R8 on Android for native code shrinking
- Audit native modules — each adds binary size
// Avoid inline styles and anonymous functions in hot paths
// BAD — new object every render
<View style={{ padding: 16 }} />
// GOOD — StyleSheet.create is optimized
const styles = StyleSheet.create({ container: { padding: 16 } });
<View style={styles.container} />
// Defer heavy work until after interactions
import { InteractionManager } from 'react-native';
InteractionManager.runAfterInteractions(() => {
loadHeavyAnalyticsData();
});
// Use native driver for animations (runs on UI thread)
Animated.timing(value, {
toValue: 1,
duration: 300,
useNativeDriver: true, // Only works for transform/opacity
}).start();Answer:
FlatList already virtualizes, but default settings often cause jank at scale. Apply these optimizations:
keyExtractor— Return a stable unique ID (never use array index for dynamic lists).getItemLayout— If row height is fixed, provide(data, index) => ({ length, offset, index })to skip layout measurement.- Memoize row component — Wrap
renderItemoutput inReact.memoand passrenderItemviauseCallback. - Tune batch props —
initialNumToRender={10},maxToRenderPerBatch={10},windowSize={5}balance memory vs scroll smoothness. removeClippedSubviews— Helps on Android by unmounting off-screen views.
For extremely demanding feeds (social media timelines), consider FlashList from Shopify which recycles native views and provides better scroll performance metrics out of the box.
Answer:
Use useMemo when deriving expensive values (filtering/sorting thousands of items, complex calculations) or when you need referential stability for dependency arrays in useEffect.
Use useCallback when passing functions to React.memo-wrapped children or to hooks that compare dependencies by reference.
They are unnecessary when:
- The computation is trivial (adding two numbers)
- The child is not memoized and re-renders regardless
- You memoize everything "just in case" — this adds overhead (comparing deps) without benefit
Rule of thumb: measure first with React DevTools Profiler, then memoize proven bottlenecks.
Answer:
Hermes is a JavaScript engine built specifically for React Native mobile apps. Unlike V8/JSC which compile at runtime, Hermes precompiles JavaScript to bytecode at build time.
Key benefits:
- Faster startup — No JIT warmup delay
- Lower memory usage — Critical for Android mid-range devices
- Smaller app size — Bytecode is compact
- Consistent performance — Predictable execution without JIT deoptimization
Hermes is the default engine in modern React Native (0.70+). It integrates with Metro bundler and supports debugging via Flipper and Chrome DevTools.
Trade-off: Hermes does not support eval() and some obscure JS features, but this rarely affects production React Native apps.
Answer:
Use a layered profiling approach:
-
React DevTools Profiler — Record component render times, identify unnecessary re-renders, check why components updated (props/state/context).
-
Flipper Performance plugin — Monitor JS and UI thread frame rates during interactions like scrolling.
-
Xcode Instruments (iOS) / Android Studio Profiler — Native-level CPU, memory, and GPU analysis.
-
Custom markers — Wrap screens in
<Profiler>or useperformance.now()to log screen load times. -
Production monitoring —
@shopify/react-native-performanceor Sentry performance tracking.
Typical findings: unmemoized FlatList rows, inline functions in lists, large images, synchronous storage reads on mount, and bridge-heavy native module calls.
Answer:
- Right-size images — Request CDN variants matching display size; never decode 4000×3000 into a 100×100 thumbnail.
- Modern formats — WebP/AVIF over PNG/JPEG where supported.
- Caching — Use
react-native-fast-imagefor aggressive disk and memory caching with cache control headers. - Prefetch — Call
Image.prefetch(url)for images on the next screen before navigation. - Local assets — Bundle icons and static images; avoid network for UI chrome.
- Progressive loading — Show placeholder/blur hash while full image loads.
- Memory management — In long image galleries, ensure off-screen images are released (FlashList helps).
Avoid resizeMode changes that trigger relayout on every render, and never store base64 images in JS state for large photos.
Answer:
JavaScript layer:
- Enable
inlineRequiresin Metro for lazy module initialization - Code-split with dynamic
import()for infrequent screens - Audit dependencies with bundle visualizer; remove unused packages
- Replace heavy libs (moment → dayjs, full lodash → per-function imports)
- Avoid importing entire icon libraries; use selective imports
Native layer:
- Enable ProGuard/R8 (Android) and strip dead code
- Review native modules — video, maps, and analytics SDKs add significant binary weight
- Use App Thinning (iOS) and Android App Bundles (AAB) for device-specific delivery
Process:
- Set bundle size budgets in CI
- Compare bundle size on every PR
- Target incremental loading for features used by minority of users
Q7: Explain the difference between the JS thread and UI thread, and how performance issues manifest on each.
Answer:
React Native has two primary threads:
JavaScript Thread:
- Runs React reconciliation, business logic, network response handling
- Blocked by: heavy computation, synchronous AsyncStorage, large JSON parsing, excessive re-renders
- Symptoms: Delayed touch response, slow navigation transitions, timers firing late
UI (Native) Thread:
- Handles layout, drawing, gestures, native animations
- Blocked by: complex view hierarchies, overdraw, non-native-driver animations, heavy native module work on main thread
- Symptoms: Scroll jank, stuttering animations, frozen gestures
The Bridge (legacy) or JSI (New Architecture) connects them. Excessive cross-thread messaging causes overhead.
Fix strategies:
- JS thread: memoization, web workers for heavy compute, defer work with
InteractionManager - UI thread:
useNativeDriver: true, simplify view hierarchy, move work off main thread in native code - Both: New Architecture reduces serialization overhead via JSI direct access
Use Flipper to see which thread drops frames during specific user interactions.
Answer:
InteractionManager is a React Native API that defers JavaScript work until animations and gestures finish, preventing frame drops when the UI thread is busy with transitions.
import { InteractionManager } from 'react-native';
import { useEffect } from 'react';
function ProductScreen() {
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
// Safe to run heavy work: analytics, prefetch, large state updates
loadRecommendations();
});
return () => task.cancel();
}, []);
return (/* ... */);
}When to use:
- After screen navigation animations complete
- Before fetching non-critical data on mount
- Before rendering heavy secondary UI (charts, maps)
When NOT to use:
- Critical path data needed immediately on screen load (use priority fetch instead)
- Replacing proper list virtualization or memoization
Interview tip: Contrast with requestAnimationFrame (next frame) and setTimeout (arbitrary delay). InteractionManager waits for the interaction/animation queue to drain — the Toptal/samsoul16 classic question.
Also mention InteractionManager.createInteractionHandle() / clearInteractionHandle() when you need to register custom long-running interactions.
Previous: Testing
Next: Animations