TL;DR
- Measure first. A before number and an after number in every PR beats every trick in this post.
- Turn on the New Architecture. Biggest single win available: ~40% faster cold start, 35 to 43% faster list rendering, ~25% lower memory.
- Confirm Hermes is on in your release build, not just dev, and upload your source maps.
- Swap
FlatListforFlashListpast ~50 items. - Every animation goes to Reanimated 4 worklets. No exceptions.
- Put a budget in CI with Flashlight so regressions break the build instead of your App Store rating.
Most React Native apps do not need more code to feel fast. They need less of the wrong code, in the right places.
The platform moved. New Architecture is default. Hermes is default. FlashList is stable. Reanimated 4 is stable. So the advice you memorized in 2022 (memoize everything, throw shouldComponentUpdate at every list item, dread the bridge) is now either handled for you or actively counterproductive.
This is the current playbook, ordered by impact. It assumes one thing: you measured the problem before you changed any code.
What "fast" actually means
"It feels slow" is not a spec. On a mid-tier Android device (Pixel 6a, Samsung A34), a shippable app in 2026 hits:
| Metric | Target |
|---|---|
| Cold start (Android) | Under 2.0s, tap to first interactive frame |
| Cold start (iPhone 13) | Under 1.2s |
| Sustained scroll | 58+ fps on 500+ item lists with images |
| Interaction latency | Under 100ms, touch to visible state change |
| JS heap | Under 180MB, no monotonic growth over 10 min |
| Install size | Under 30MB base binary, App Store thin variant |
If you cannot state your current numbers against these, that is the first fix.
1. Profile first, stop guessing
React Native's performance surface spans three worlds: the JS thread, the UI/main thread, and native modules. A bug in one looks identical to a bug in another until you profile.
- Hermes Sampling Profiler. Ships with RN, near-zero overhead, flame graphs open in Chrome DevTools or Perfetto. Start here every time.
- React DevTools Profiler. Commit-by-commit view of what rendered and why. The "Highlight updates when components render" toggle finds re-render storms in about ten seconds.
- Flashlight. CLI for FPS, CPU, memory and JS thread health during scripted runs. Puts real numbers on regressions.
- Perfetto (Android) / Instruments (iOS). For anything crossing the native boundary: cold start, module init, layout, gestures.
- Expo's perf inspector. FPS meter and JS heap sampler in the dev menu. Good enough for 80% of day-to-day work.
The rule that matters more than any tool: before number, after number, both in the PR description. No numbers, no merge. This single policy will make your app faster than any optimization on this list.
2. Be on the New Architecture
RN 0.76 made it the default. Expo SDK 52 followed. If you are still opted out you are leaving 30 to 40% of your cold start and roughly all of your bridge overhead on the table.
Three pieces:
- JSI replaces the async JSON bridge with direct synchronous calls. Serialize, queue, deserialize, return, deserialize is now a function pointer.
- Fabric is the new renderer. Concurrent-aware, layout on the UI thread, no more commit storms during scroll-plus-fetch.
- TurboModules load lazily on first use. That alone can shave 200 to 400ms off cold start in apps with a lot of linked modules.
Enabling it is a flag:
// app.json (Expo)
{
"expo": {
"newArchEnabled": true
}
}
# gradle.properties (bare)
newArchEnabled=true
// ios/Podfile.properties.json (bare)
{
"newArchEnabled": "true"
}
What breaks is third-party native modules that never got upgraded. Check each dependency's issue tracker before you flip it.
Teams that finished the migration in 2025 consistently report cold start down ~40%, list rendering up ~35 to 43%, memory down ~25%, and frame rates going from the high 40s to a steady 58 to 59 fps. That is not incremental. It is the largest performance change RN has ever shipped.
3. Hermes is default, so do not break it
Hermes is the engine on both platforms now. The value is not just runtime speed, it is ahead-of-time bytecode compilation at build time. You ship precompiled bytecode instead of raw JS, which is why cold starts land 20 to 40% ahead of JSC.
Three things to actually do:
-
Confirm Hermes is on in release, not just dev. Easy to miss when migrating off JSC.
hermesEnabled=trueingradle.properties,:hermes_enabled => truein the Podfile, or the Expo equivalent. - Precompile bytecode in CI so it is baked at build time instead of first launch on device.
- Upload your source maps. Hermes bytecode makes Sentry and Bugsnag stack traces unreadable without them. This is the footgun that bites teams in production, every time.
You do not need to tune the engine. You just need to not accidentally disable it.
4. FlashList for every list that matters
FlatList is fine for short lists. Past ~50 items, images, or variable row heights, use FlashList. This is the highest-leverage optimization that requires you to write actual code.
FlashList recycles cells instead of unmounting them and does not allocate a new view per item. On a Pixel 6a with 1,000 image-plus-text rows:
| FlatList | FlashList | |
|---|---|---|
| Sustained FPS | 30 to 40, with freezes | 58 to 60 |
| Cell handling | Unmount and remount | Recycled |
| Views allocated | One per item | Pooled |
import { FlashList } from "@shopify/flash-list";
const Row = React.memo(({ item, onPress }) => (
<Pressable onPress={onPress} style={styles.row}>
<FastImage source={{ uri: item.thumb }} style={styles.thumb} />
<Text style={styles.title}>{item.title}</Text>
</Pressable>
));
export function Feed({ data, onSelect }) {
return (
<FlashList
data={data}
estimatedItemSize={88}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} onPress={onSelect} />}
/>
);
}
Two rules to actually get the speedup:
-
Set
estimatedItemSize. Off by 30% and you still get most of the win. Missing entirely and FlashList falls back to slow measurement passes. -
No dynamic per-item work inside
renderItemclosures. Memoize row components and their handlers.
Variable heights you cannot estimate? Use the median. Do not overthink it.
5. Kill re-renders, but only the ones that matter
Re-renders are the most over-optimized problem in React Native. Wrapping everything in React.memo bloats your bundle and slows mounting. Profile first, memoize where the profiler shows a hot path.
That said, four anti-patterns cause 80%+ of real re-render bugs.
Inline object props. New object identity every parent render, which invalidates every downstream React.memo.
// Bad
<Card style={{ margin: 8 }} />
// Good
const styles = StyleSheet.create({ card: { margin: 8 } });
<Card style={styles.card} />
Inline handler props. <Button onPress={() => doThing(id)} /> is a new function every render. useCallback, or better, move the handler into a memoized child that owns the id.
Context provider sprawl. Every value change re-renders every consumer. Split by update frequency: identity (rare), theme (rare), live data (constant). Do not put them in one provider.
Selector reference issues. Redux/Zustand selectors returning fresh object references re-render every consumer even when values are identical.
// Bad: new object every store update
const { name, avatar } = useStore((s) => ({ name: s.name, avatar: s.avatar }));
// Good
import { useShallow } from "zustand/react/shallow";
const { name, avatar } = useStore(
useShallow((s) => ({ name: s.name, avatar: s.avatar }))
);
why-did-you-render is still the fastest way to find these in dev.
6. Get animations off the JS thread
An animation on the JS thread drops frames the moment JS is busy, which is always, because JS is where your business logic lives.
- Reanimated 4 for state, gesture, or timing driven animation. Worklets run on the UI thread as native functions. A 60fps spring stays at 60fps while the JS thread parses a 500KB JSON response.
-
Gesture Handler for drags, swipes, pinches.
PanResponderis JS-thread-bound and will jank. - Skia for anything painterly: charts, custom drawing, complex transitions. Bypasses the React view tree, renders straight to a GPU canvas.
If you are writing Animated.Value with useNativeDriver: false, stop. Convert it, or find out why useNativeDriver: true is not an option (usually it is a prop Reanimated supports and old Animated does not).
7. Cold start, the number users actually notice
Native init + bundle load + bundle execute + first render. Also the metric App Store reviewers judge you on. In order of impact:
- New Architecture on. Biggest win.
- Hermes bytecode precompiled. Second biggest.
-
Bundle size. Every 100KB of JS costs 20 to 40ms of parse-plus-execute on mid-tier Android. Run
npx react-native-bundle-visualizerand delete what surprises you.moment.js, fulllodash, and five date-picker libraries you forgot about are the usual suspects. -
Lazy-load routes.
React.lazy+Suspenseat route boundaries. Your login screen does not need to parse the dashboard's 400KB. - Native module audit. Every linked module runs init at startup. Removing the JS import does not unlink the native code.
-
Font loading. Preload only the weights on the first screen.
Font.loadAsyncfor 12 weights blocks first paint for hundreds of ms. - Splash strategy. Hold the native splash until the first interactive screen is ready. Cross-fade early and you get a flash of empty content.
Instrument with AppRegistry.setWrapperComponentProvider plus a manual mark at first meaningful paint, log to analytics, and watch the median. Not the mean. The mean lies.
8. Memory, the silent killer
Leaks rarely crash a React Native app. They just make it slower, and slower, until the OS kills it in the background and the user thinks "the app forgot me." Three sources cover nearly every leak in the wild:
-
Uncleaned listeners. Every
addEventListener,subscribe, andAppStatehandler needs cleanup in theuseEffectreturn. exhaustive-deps will not catch these. -
Unbounded image cache.
react-native-fast-imagecaches aggressively. Set amaxMemoryPolicyor hold hundreds of MB of thumbnails. -
Uncleared timers.
setIntervalin a component that mounts and unmounts on navigation is the classic.
useEffect(() => {
const sub = AppState.addEventListener("change", onChange);
const id = setInterval(poll, 5000);
return () => {
sub.remove();
clearInterval(id);
};
}, [onChange, poll]);
Heap snapshot at 10 minutes, another at 30, compare. Delta not roughly flat means you have a leak. Instruments Allocations on iOS, Android Studio Memory Profiler on Android.
9. Network, the invisible half of "fast"
Users do not distinguish "the app is slow" from "the network is slow." Hide the network from them.
- TanStack Query for every fetch with a cache key. Dedup, background refetch, stale-while-revalidate for free. The single library that most improved perceived RN performance in the last two years.
- HTTP/2 or HTTP/3. On HTTP/1.1 you pay a full round trip per request. Cloudflare, Fastly, AWS ALB all default to HTTP/2 now.
- Image CDN with responsive sizing. Serve 400x400 to a phone, not 4000x4000.
-
Promise.allfor anything that does not depend on a previous result. - Optimistic updates for any action that succeeds 99% of the time. Users feel every millisecond of spinner.
10. Ship a performance budget in CI
All of the above is worthless if someone ships a regression next Tuesday.
Flashlight runs a scripted E2E test on a real or emulated Android device, records CPU/FPS/memory, and fails the build when a metric exceeds budget. Point it at five flows (cold start, login, main list scroll, detail view, checkout), set budgets 10 to 20% above current numbers, and now the regression conversation happens in code review instead of in your App Store reviews six weeks later.
When NOT to optimize
- The profiler shows no hotspot. You are not fixing anything, you are writing code.
- The user cannot perceive it. Nobody notices 12ms to 8ms on a screen already at 60fps. They notice 30fps to 60fps.
- It removes a real feature. Slower with the feature beats faster without it, unless the feature is optional.
Where AI-generated code fits
Here is the part that surprised me. Generating a React Native project with an AI builder like RapidNative now gives you the New Architecture enabled, Hermes on, FlashList on meaningful lists, Reanimated wired up, and TanStack Query for data, because those are the current defaults and the model trained on the current stack. Steps 2, 3, 4 and 6 of this playbook come pre-done.
What it cannot do is your measurement work. It does not know your product screen renders 400 image cards and needs a memoized row, or that your context provider grew three orders of magnitude too much state. You get the correct baseline for free. The profiling and tuning is still yours.
So the fast path in 2026: generate the correct baseline, ship v1, measure real user performance, apply this playbook to whatever the profiler actually flags. Skip the six weeks of boilerplate and spend them on the parts of the app only you understand.
What is the biggest perf win you have shipped this year? Drop it in the comments, especially the ones that surprised you after profiling. I want to know which of these ten actually moved your numbers and which did nothing.
Top comments (0)