216 lines
6.9 KiB
Markdown
216 lines
6.9 KiB
Markdown
|
|
# Performance Reference
|
||
|
|
|
||
|
|
Diagnosing and fixing performance issues in React Native / Expo apps.
|
||
|
|
|
||
|
|
## Profiling Workflow
|
||
|
|
|
||
|
|
Before optimizing, identify the actual bottleneck:
|
||
|
|
|
||
|
|
1. **JS thread** — Open React Native DevTools (press `j` in Metro terminal) → Profiler tab → record interaction → look for components with long render times
|
||
|
|
2. **Native thread** — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler
|
||
|
|
3. **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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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 `memo` to skip re-renders when props haven't changed
|
||
|
|
- Always provide `estimatedItemSize` — FlashList uses it for layout estimation
|
||
|
|
- Extract `renderItem` or 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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```json
|
||
|
|
// 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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
const ref = useRef<TextInput>(null);
|
||
|
|
|
||
|
|
<TextInput
|
||
|
|
ref={ref}
|
||
|
|
defaultValue=""
|
||
|
|
onEndEditing={(e) => handleSearch(e.nativeEvent.text)}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
## Startup Time (TTI)
|
||
|
|
|
||
|
|
### Measuring
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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=false` in `android/gradle.properties` so Hermes memory-maps the bundle instead of decompressing it
|
||
|
|
- **Preload heavy routes** — Call `preloadRouteAsync("/dashboard")` (from `expo-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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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 of `import { groupBy } from "lodash"`
|
||
|
|
- **Remove dead Intl polyfills** — Hermes ships with built-in `Intl` support since SDK 50
|
||
|
|
- **Tree shaking** — Enable via `"experiments": { "treeShaking": true }` in app config (SDK 52+)
|
||
|
|
|
||
|
|
### Shrinking the Native Binary
|
||
|
|
|
||
|
|
```properties
|
||
|
|
# android/gradle.properties
|
||
|
|
android.enableProguardInReleaseBuilds=true
|
||
|
|
```
|
||
|
|
|
||
|
|
Inspect the final artifact:
|
||
|
|
- iOS: download the `.ipa` from EAS, unzip, check `Payload/*.app` size
|
||
|
|
- Android: open the `.aab`/`.apk` in Android Studio → Build → Analyze APK
|
||
|
|
|
||
|
|
## Memory
|
||
|
|
|
||
|
|
### Preventing Leaks
|
||
|
|
|
||
|
|
Every subscription, listener, or long-lived resource acquired in `useEffect` must be cleaned up:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
useEffect(() => {
|
||
|
|
const sub = AppState.addEventListener("change", onAppStateChange);
|
||
|
|
return () => sub.remove();
|
||
|
|
}, []);
|
||
|
|
```
|
||
|
|
|
||
|
|
For fetch calls, pass an `AbortSignal` and abort on unmount:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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-crypto` over `crypto-js`, `react-native-mmkv` over AsyncStorage for hot paths)
|
||
|
|
- **Android 16KB page alignment** is required for Google Play (2025+). Verify third-party `.so` files 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 |
|