6.9 KiB
Performance Reference
Diagnosing and fixing performance issues in React Native / Expo apps.
Profiling Workflow
Before optimizing, identify the actual bottleneck:
- JS thread — Open React Native DevTools (press
jin Metro terminal) → Profiler tab → record interaction → look for components with long render times - Native thread — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler
- Measure, don't guess — Always reproduce the issue in a release-like build (
npx expo run:ios --configuration Release)
Rendering
Virtualized Lists
Never render large datasets inside a ScrollView. Use a virtualized list that recycles off-screen views:
import { FlashList } from "@shopify/flash-list";
function ProductCatalog({ products }: { products: Product[] }) {
return (
<FlashList
data={products}
renderItem={({ item }) => <ProductRow product={item} />}
estimatedItemSize={72}
keyExtractor={(p) => p.sku}
/>
);
}
const ProductRow = memo(function ProductRow({ product }: { product: Product }) {
return (
<View style={rowStyles.container}>
<Image source={product.thumbnail} style={rowStyles.image} />
<Text style={rowStyles.title}>{product.name}</Text>
</View>
);
});
Key points:
- Wrap list items with
memoto skip re-renders when props haven't changed - Always provide
estimatedItemSize— FlashList uses it for layout estimation - Extract
renderItemor use a named component to keep stable references
Minimizing Re-renders
Split state by concern. A single large state object forces every subscriber to re-render on any change:
// Zustand — select only the slice you need
const count = useStore((s) => s.cart.itemCount);
// Jotai — one atom per concern
const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
});
React Compiler (Expo SDK 54+) automatically memoizes components and hooks. Enable it to eliminate most manual useMemo/useCallback:
// app.json
{ "expo": { "experiments": { "reactCompiler": true } } }
Deferred Updates
When a state change triggers expensive computation (filtering a long list, rendering a complex tree), defer the update so typing or scrolling stays responsive:
const [search, setSearch] = useState("");
const deferred = useDeferredValue(search);
const results = useMemo(
() => catalog.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),
[catalog, deferred],
);
TextInput on Android
Controlled TextInput (with value + onChangeText) can lag on Android because every keystroke round-trips through the JS thread. For search bars or other high-frequency inputs, prefer uncontrolled mode:
const ref = useRef<TextInput>(null);
<TextInput
ref={ref}
defaultValue=""
onEndEditing={(e) => handleSearch(e.nativeEvent.text)}
/>
Startup Time (TTI)
Measuring
import { PerformanceObserver, performance } from "react-native-performance";
performance.mark("nativeLaunch");
export default function App() {
useEffect(() => {
performance.measure("TTI", "nativeLaunch");
}, []);
// ...
}
Always measure cold starts — kill the app completely before each measurement.
Reducing TTI
- Android bundle mmap — Set
expo.useLegacyPackaging=falseinandroid/gradle.propertiesso Hermes memory-maps the bundle instead of decompressing it - Preload heavy routes — Call
preloadRouteAsync("/dashboard")(fromexpo-router) while the user is still on the splash/login screen - Lazy-load non-critical screens — Screens behind deep navigation don't need to be in the initial bundle
Bundle & App Size
Inspecting the Bundle
npx expo export --platform ios --source-maps --output-dir dist
npx source-map-explorer dist/bundles/ios/*.js
Common wins:
- Direct imports —
import groupBy from "lodash/groupBy"instead ofimport { groupBy } from "lodash" - Remove dead Intl polyfills — Hermes ships with built-in
Intlsupport since SDK 50 - Tree shaking — Enable via
"experiments": { "treeShaking": true }in app config (SDK 52+)
Shrinking the Native Binary
# android/gradle.properties
android.enableProguardInReleaseBuilds=true
Inspect the final artifact:
- iOS: download the
.ipafrom EAS, unzip, checkPayload/*.appsize - Android: open the
.aab/.apkin Android Studio → Build → Analyze APK
Memory
Preventing Leaks
Every subscription, listener, or long-lived resource acquired in useEffect must be cleaned up:
useEffect(() => {
const sub = AppState.addEventListener("change", onAppStateChange);
return () => sub.remove();
}, []);
For fetch calls, pass an AbortSignal and abort on unmount:
useEffect(() => {
const ac = new AbortController();
loadProducts(ac.signal);
return () => ac.abort();
}, [categoryId]);
Native Memory
- Monitor with Xcode Memory Graph Debugger (iOS) or Android Studio Memory Profiler
- Release heavy native resources (camera sessions, audio players) in cleanup
- In Swift/Kotlin modules, watch for retain cycles — use
[weak self]/WeakReference
Animations
Only animate transform and opacity. These properties are composited on the GPU and don't trigger layout recalculation:
const style = useAnimatedStyle(() => ({
opacity: withTiming(visible.value ? 1 : 0),
transform: [{ translateY: withSpring(offset.value) }],
}));
Animating width, height, margin, padding, or top/left forces the layout engine to re-measure on every frame — a common source of dropped frames.
Keep gesture callbacks on the UI thread:
const drag = Gesture.Pan().onUpdate((e) => {
"worklet";
translateX.value = e.translationX;
});
Native Module Performance
- Prefer async Turbo Module methods — synchronous calls block the JS thread
- Use native SDK implementations over JS polyfills (
expo-cryptoovercrypto-js,react-native-mmkvover AsyncStorage for hot paths) - Android 16KB page alignment is required for Google Play (2025+). Verify third-party
.sofiles are compiled with 16KB alignment
Troubleshooting Guide
| Symptom | Where to Look | Likely Fix |
|---|---|---|
| Scroll jank in long lists | JS thread — re-renders | Virtualized list + memoized items |
| Typing lag in search bar | JS thread — controlled input | Uncontrolled TextInput with defaultValue |
| Slow cold start | Bundle size, sync init | Mmap bundle, preload routes, lazy screens |
| App binary too large | Native assets, unused libs | R8 (Android), analyze bundle, direct imports |
| Growing memory over time | Effect cleanup | Return teardown from every useEffect |
| Choppy enter/exit animation | Animated properties | Only transform + opacity, use worklets |
| Re-renders cascade across app | Global state shape | Atomic selectors (Zustand/Jotai), React Compiler |