Files
2026-03-26 20:32:52 +08:00

6.9 KiB

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:

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:

// 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=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

npx expo export --platform ios --source-maps --output-dir dist
npx source-map-explorer dist/bundles/ios/*.js

Common wins:

  • Direct importsimport 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

# 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:

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-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