React Native performance optimization is the practice of keeping an app rendering at 60 or 120 frames per second by managing three separate threads: the JavaScript thread that runs your code, the UI thread that draws pixels, and the native modules thread. Because your React code executes on a different thread than the one painting the screen, any long-running JavaScript work blocks interaction and produces visible jank. The highest-impact techniques are enabling the Hermes engine, cutting startup time with inline requires and lazy screens, replacing FlatList with FlashList for long lists, eliminating unnecessary re-renders with memoization, running animations on the UI thread via Reanimated worklets, optimizing images with expo-image, and hunting memory leaks from listeners and timers. The New Architecture (JSI, Fabric, TurboModules) removes bridge serialization overhead and adds further gains. This guide covers the threading model, how to measure jank properly, and each optimization with concrete code.
How Does the React Native Threading Model Work?
A React Native app runs your logic across several threads, and jank almost always comes from one of them being overloaded. There are three that matter most:
The threads that decide whether your app feels smooth:
- JS thread: runs your JavaScript/TypeScript, React reconciliation, business logic, network callbacks, and event handlers. Single-threaded, so anything expensive here blocks everything else your app does in JS.
- UI (main) thread: draws the native views, processes touch events, and runs native animations. If it is blocked, the entire interface freezes and touches are dropped.
- Native modules / shadow thread: layout calculation (Yoga), and background work exposed by native modules. In the old architecture this communicated with JS over the asynchronous bridge.
In the classic architecture, the JS thread and the UI thread talk to each other over the "bridge" — an asynchronous, batched, JSON-serialized message queue. Every gesture that needs a JS decision, every state update that changes a native view, has to cross that bridge. When you scroll a list and each frame requires the JS thread to compute new items, serialize them, and send them across, you get the classic "the JS thread is busy so the list stutters" problem.
Why jank happens
The display refreshes every ~16.67ms at 60Hz (or ~8.3ms at 120Hz). To render a frame, the UI thread needs its work done inside that budget. Jank appears when either (a) the JS thread takes too long to produce the next update — a heavy `map`, an unmemoized computation, a huge re-render — so the UI thread has nothing new to show, or (b) the UI thread itself is blocked, for example by an animation driven from JS that needs a value every frame. The fix is almost always the same idea: keep work off the critical path of the thread that needs to hit the frame budget.
Performance work in React Native is rarely about writing "faster" code in the abstract. It is about deciding which thread does the work, and making sure the thread that draws the screen is never waiting on JavaScript.
Measure First: You Cannot Optimize What You Cannot See
Before touching a single line, establish a baseline. Guessing where the slowdown is wastes days. Here are the tools that actually pay off.
The measurement toolkit:
- Perf Monitor (in-app dev menu): shows JS and UI frame rates live. If UI FPS drops during an animation, the main thread is blocked; if JS FPS drops during interaction, your JS thread is the bottleneck.
- React DevTools Profiler: records commits and flame graphs so you can see which components re-rendered, how often, and why. The "Highlight updates when components render" toggle is invaluable for spotting runaway re-renders.
- Hermes sampling profiler: capture a CPU profile of the JS thread and open it in Chrome DevTools or Flipper to find hot functions and long tasks.
- systrace / Perfetto (Android) and Instruments (iOS): native-level tracing when the bottleneck is layout, image decoding, or a native module rather than JS.
- Flipper (or the newer built-in dev tooling): plugins for network, layout, and performance in one place.
Capturing a Hermes CPU profile
The Hermes profiler is the fastest way to find a slow JS function. You can trigger it from the dev menu, or programmatically wrap a suspicious code path and inspect the resulting trace.
# Pull a Hermes CPU profile off an Android device after recording
# from the in-app dev menu (Enable Sampling Profiler -> interact -> Disable)
adb pull /data/user/0/com.yourapp/cache/*.cpuprofile ./profiles/
# Convert / open in Chrome DevTools: chrome://inspect -> "Open dedicated
# DevTools for Node" -> Performance tab -> load the .cpuprofile file
# Measure cold start time on Android (time to first frame)
adb shell am start -W -n com.yourapp/.MainActivityThe `am start -W` output gives you `TotalTime` and `WaitTime` in milliseconds — a hard number for cold-start regressions you can track in CI. On iOS, use the "App Launch" template in Instruments for the equivalent.
What Does Hermes Do for Performance?
Hermes is the JavaScript engine built specifically for React Native, and it is the default in modern versions. Its biggest win is ahead-of-time compilation: instead of shipping raw JavaScript that the engine must parse and compile on the device at startup, Hermes precompiles your bundle to bytecode at build time. That means the phone loads bytecode directly, dramatically cutting time-to-interactive and reducing memory pressure.
What Hermes buys you:
- Faster startup: no on-device parse/compile of a multi-megabyte JS string; bytecode is memory-mapped and ready to execute.
- Lower memory footprint: a garbage collector and object model tuned for constrained mobile devices.
- Smaller download and install size compared to bundling a general-purpose engine.
- A first-class sampling profiler and good source-map support for debugging production crashes.
Confirm Hermes is actually running at runtime — a surprising number of "why is startup slow" issues are simply Hermes being disabled in a config:
// Quick runtime check — logs true when Hermes is the active engine
const isHermes = () => !!(global as unknown as { HermesInternal?: object }).HermesInternal;
if (__DEV__) {
console.log('Hermes enabled:', isHermes());
}How Do You Reduce Startup Time?
Startup is the first impression, and it is dominated by how much JavaScript has to be loaded and executed before the first screen appears. The goal is to defer everything that is not needed for that first frame.
Inline requires and lazy execution
By default, all `import` statements at the top of a module execute their target modules eagerly the moment the parent module loads. Inline requires (enabled through the Metro `transformer.getTransformOptions` config, and on by default in newer templates) rewrite imports so a module is only evaluated the first time it is actually used. Combined with lazy-loaded screens, this means code for a settings screen the user may never open does not run during startup.
// metro.config.js — ensure inline requires and RAM-bundle-style
// lazy evaluation are enabled for release builds
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
// Evaluate modules lazily on first use instead of eagerly at load
inlineRequires: true,
experimentalImportSupport: false,
},
});
module.exports = config;Lazy screens with React.lazy and Suspense
Route-level code splitting keeps the initial bundle execution small. Screens that are not part of the first paint are loaded only when navigated to.
import React, { Suspense, lazy } from 'react';
import { ActivityIndicator, View } from 'react-native';
// Heavy screens are only evaluated when the route is visited
const AnalyticsScreen = lazy(() => import('../screens/AnalyticsScreen'));
const ScreenFallback = () => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator />
</View>
);
export const Analytics = () => (
<Suspense fallback={<ScreenFallback />}>
<AnalyticsScreen />
</Suspense>
);Other high-impact startup wins:
- Shrink the bundle: audit with a bundle visualizer, drop moment.js for date-fns/dayjs, avoid importing entire icon or lodash packages when you need one function.
- RAM bundles / bytecode: with Hermes you already ship bytecode; on the classic architecture, RAM bundles load modules on demand rather than parsing the whole bundle upfront.
- Defer non-critical init: analytics, crash reporters, feature-flag fetches, and remote config should initialize after the first interactive frame — use InteractionManager.runAfterInteractions or a short timeout.
- Keep the native side lean: fewer autolinked native modules means less to initialize before JS even starts.
import { InteractionManager } from 'react-native';
// Push non-essential setup off the startup critical path
InteractionManager.runAfterInteractions(() => {
initAnalytics();
fetchRemoteConfig();
warmUpImageCache();
});Why Are Lists the Most Common Bottleneck?
Long, scrollable lists are where React Native apps most often fall apart. `FlatList` virtualizes items, but it still keeps view instances around and can struggle with large or heterogeneous data. Shopify's `FlashList` is a near drop-in replacement that recycles views (like native `RecyclerView`/`UICollectionView`) instead of unmounting and remounting them, which cuts memory and blank-cell flicker dramatically.
import React, { memo, useCallback } from 'react';
import { Text, View, StyleSheet } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
type Product = { id: string; name: string; price: number };
// Rows are memoized so unchanged items never re-render during scroll
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 const ProductList = ({ data }: { data: Product[] }) => {
const renderItem: ListRenderItem<Product> = useCallback(
({ item }) => <ProductRow item={item} />,
[]
);
// Stable keys let the recycler match items correctly across updates
const keyExtractor = useCallback((item: Product) => item.id, []);
return (
<FlashList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={64}
/>
);
};
const styles = StyleSheet.create({
row: {
height: 64,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
name: { fontSize: 16, fontWeight: '600' },
price: { fontSize: 14, color: '#6b7280' },
});List performance checklist:
- Provide a stable, unique key via keyExtractor — never the array index for data that reorders, inserts, or deletes.
- Memoize the row component and pass it primitive or stable props so recycled cells do not re-render needlessly.
- Wrap renderItem and keyExtractor in useCallback so their identity is stable across parent renders.
- Give FlashList a realistic estimatedItemSize; a bad estimate hurts initial layout and scroll accuracy.
- Avoid inline arrow functions and object literals in the item JSX — they defeat memoization by creating new references every render.
- Keep row components shallow; deep view hierarchies per cell multiply layout cost across hundreds of items.
How Do You Avoid Unnecessary Re-renders?
React re-renders a component when its state or props change, and it re-renders all children by default. In a large tree, a single high-frequency state update near the root can cascade into hundreds of wasted renders. The three primitives you reach for are `React.memo`, `useCallback`, and `useMemo` — but they only help when the props they guard are actually stable.
import React, { memo, useCallback, useMemo, useState } from 'react';
import { Button, FlatList, Text, View } from 'react-native';
type Item = { id: string; label: string };
// memo short-circuits re-render when props are referentially equal
const ListRow = memo(({ item, onPress }: { item: Item; onPress: (id: string) => void }) => {
console.log('render row', item.id); // fires only when this row's props change
return (
<View>
<Text onPress={() => onPress(item.id)}>{item.label}</Text>
</View>
);
});
export const Screen = ({ items }: { items: Item[] }) => {
const [count, setCount] = useState(0);
// Stable callback identity -> memoized rows are not invalidated when count changes
const handlePress = useCallback((id: string) => {
console.log('pressed', id);
}, []);
// Expensive derived value is recomputed only when items change
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.label.localeCompare(b.label)),
[items]
);
return (
<View style={{ flex: 1 }}>
<Button title={`Count: ${count}`} onPress={() => setCount((c) => c + 1)} />
<FlatList
data={sortedItems}
keyExtractor={(i) => i.id}
renderItem={({ item }) => <ListRow item={item} onPress={handlePress} />}
/>
</View>
);
};The critical detail: pressing the button changes `count`, which re-renders `Screen`. Without `useCallback`, `handlePress` would be a brand-new function each render, breaking `React.memo` on every row. With it, the rows stay stable and only the button updates. The same logic applies to object and array props — inline `style={{ margin: 8 }}` creates a new object every render, so hoist static styles into `StyleSheet.create` or memoize dynamic ones.
Rules of thumb for stable props:
- Do not micro-optimize everything — memoization has its own cost. Profile first, then wrap the components that actually re-render hot.
- Split large components so localized state updates do not re-render unrelated subtrees.
- Move fast-changing state (like a text input value) as low in the tree as possible, close to where it is used.
- Prefer selectors (Zustand, Redux Toolkit) that subscribe to a slice of state rather than the whole store.
Animations and Gestures on the UI Thread
Animating from the JS thread with the classic `Animated` API means every frame may require a round trip: JS computes a value, sends it across the bridge, the UI thread applies it. If the JS thread is busy, the animation stutters. `react-native-reanimated` solves this by running animation logic in "worklets" — small functions that execute directly on the UI thread — so animations keep running at full frame rate even when JavaScript is blocked.
import React from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export const DraggableCard = ({ onSettled }: { onSettled: () => void }) => {
// Shared values live on the UI thread and drive animations without JS
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const pan = Gesture.Pan()
.onChange((event) => {
// This runs on the UI thread every frame — no bridge round trip
translateX.value += event.changeX;
translateY.value += event.changeY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
// Hop back to the JS thread only when we need to call JS code
runOnJS(onSettled)();
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]} />
</GestureDetector>
);
};
const styles = StyleSheet.create({
card: {
width: 120,
height: 160,
borderRadius: 16,
backgroundColor: '#2563eb',
},
});Notice the discipline around thread boundaries: the gesture handler and `useAnimatedStyle` worklets run on the UI thread, so dragging is silky even under load. `runOnJS` is the explicit escape hatch to call back into JavaScript — use it sparingly and only when you truly need JS-side logic, because every hop has a cost. Pair Reanimated with `react-native-gesture-handler`, which also processes touches natively rather than routing them through JS.
Image Optimization
Images are frequently the biggest source of memory pressure and scroll jank. Decoding a full-resolution photo to display it in a 100px thumbnail wastes memory and CPU. The `expo-image` component (backed by native libraries like SDWebImage and Glide) gives you disk and memory caching, smart downscaling, progressive loading, and placeholders out of the box.
import React from 'react';
import { Image } from 'expo-image';
import { StyleSheet } from 'react-native';
// A tiny blurhash placeholder avoids layout shift and blank space
const blurhash = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4';
export const Avatar = ({ uri }: { uri: string }) => (
<Image
style={styles.avatar}
source={{ uri }}
placeholder={{ blurhash }}
contentFit="cover"
transition={200}
// Cache decoded images on disk and in memory across sessions
cachePolicy="memory-disk"
// Hint the target size so the native layer downsamples appropriately
recyclingKey={uri}
/>
);
const styles = StyleSheet.create({
avatar: { width: 48, height: 48, borderRadius: 24 },
});Image best practices:
- Serve appropriately sized assets from your backend/CDN — request a thumbnail URL, not the original, for list rows.
- Set an explicit width and height (or aspect ratio) so layout is stable and you avoid content shift while images load.
- Use cachePolicy to keep decoded images in memory and on disk so re-scrolls and revisits are instant.
- Prefer modern formats (WebP/AVIF) which decode smaller and faster than equivalent-quality JPEGs.
- Give recycled list images a recyclingKey so the native layer can reuse the view without flashing the previous image.
How Do You Find and Fix Memory Leaks?
A memory leak in React Native usually means you subscribed to something and never unsubscribed. Over time, retained closures and detached components pile up, garbage collection runs more often, and the app slows down or crashes on lower-end devices. The pattern to internalize: every subscription, listener, timer, or async task started in an effect must be torn down in that effect's cleanup function.
import { useEffect, useRef, useState } from 'react';
import { AppState, Keyboard } from 'react-native';
export const useConnectionStatus = () => {
const [active, setActive] = useState(true);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
// 1. Event listener — must be removed on unmount
const appStateSub = AppState.addEventListener('change', (state) => {
setActive(state === 'active');
});
// 2. Keyboard listener
const kbSub = Keyboard.addListener('keyboardDidShow', () => {});
// 3. Interval timer — must be cleared
timerRef.current = setInterval(() => {
/* poll something */
}, 5000);
// 4. Async work — guard state updates after unmount
let cancelled = false;
(async () => {
const data = await fetch('/status').then((r) => r.json());
if (!cancelled) setActive(data.online);
})();
return () => {
appStateSub.remove();
kbSub.remove();
if (timerRef.current) clearInterval(timerRef.current);
cancelled = true;
};
}, []);
return active;
};Common leak sources to audit:
- Event listeners (AppState, Keyboard, Dimensions, Linking, navigation focus) added without a matching remove().
- setInterval / setTimeout that are never cleared, especially in components that mount and unmount frequently.
- WebSocket, EventSource, and third-party SDK subscriptions left open after the screen closes.
- Async callbacks that call setState on an unmounted component — guard with a cancelled flag or AbortController.
- Large data held in closures or module-level caches that are never evicted; use bounded caches with a max size.
New Architecture Performance Gains
The New Architecture replaces the asynchronous bridge with a fundamentally faster foundation. Three pieces matter for performance: the JSI (JavaScript Interface) lets JS call native functions directly and synchronously via C++, eliminating JSON serialization; TurboModules load native modules lazily and expose them through JSI; and Fabric is the new rendering system that lets React commit layout changes to native views more efficiently, including synchronous, concurrent-friendly updates.
What the New Architecture unlocks:
- No bridge serialization: direct JSI calls remove the JSON encode/decode tax on every native interaction, which is huge for chatty modules and gestures.
- Synchronous access when needed: measure a layout or read a native value without an async round trip, enabling smoother scroll-linked effects.
- Lazy TurboModules: native modules initialize on first use, trimming startup work.
- Concurrent React support: Fabric plays nicely with features like Suspense and transitions, so heavy updates can be interruptible and prioritized.
- Better interop for libraries like Reanimated and gesture-handler that already lean on JSI for UI-thread execution.
Adopting the New Architecture is increasingly the default in recent Expo SDKs and React Native releases. The practical takeaway: keep your dependencies current, verify your critical native libraries support Fabric/TurboModules, and re-run your startup and scroll benchmarks after migrating — most apps see measurable improvements in interaction latency with no code changes beyond the upgrade.
Key Takeaways
If you remember nothing else:
- Think in threads: keep the JS thread free to produce updates and never block the UI thread that draws frames.
- Measure before optimizing — use the Perf Monitor, React DevTools Profiler, and the Hermes sampling profiler to find the real bottleneck.
- Hermes is your baseline: precompiled bytecode means faster startup and lower memory; confirm it is actually enabled.
- Cut startup work with inline requires, lazy screens, a lean bundle, and deferring non-critical initialization until after the first frame.
- Use FlashList with stable keys, memoized rows, and useCallback-wrapped renderers for large lists.
- Stabilize props with React.memo, useCallback, and useMemo — but only where profiling shows wasted renders.
- Run animations and gestures on the UI thread with Reanimated worklets and gesture-handler; reach for runOnJS sparingly.
- Optimize images with expo-image: right-sized assets, caching, modern formats, and explicit dimensions.
- Prevent leaks by cleaning up every listener, timer, subscription, and async task in effect cleanups.
- Adopt the New Architecture (JSI, TurboModules, Fabric) to remove bridge overhead and unlock concurrent rendering.
