There are two threads. Your JavaScript runs on one; the UI is drawn on the other. Almost every performance problem in React Native is the first one being too busy to answer the second in time.
What actually makes an app feel slow
In rough order of how often it is the culprit:
Too much work per render, usually a list re-rendering every row when one changed. A long list without virtualisation. An animation on the JavaScript thread. Large images. A slow startup, which is the one users judge you on hardest.
Notice what is not on the list: the framework. If your app is janky, it is almost certainly something above and not React Native being React Native.
memo and useCallback are a pair
This is the single most useful optimisation, and the one most often applied incorrectly.
export const ProductCard = memo(function ProductCard({ product, onSelect }: Props) {memo skips re-rendering a component when its props are unchanged, compared shallowly.
The menu renders a card per product, so without it, opening the cart sheet — which changes state in
a parent — re-renders every card even though not one of their props changed.
But it only works if the props are referentially stable. An inline arrow is a new function on every render, so every card sees a "changed" prop and re-renders anyway. Hence:
const handleSelect = useCallback((product: Product) => {
setSelectedProduct(product);
setOpenCount((count) => count + 1);
}, []);memo without useCallback achieves nothing except an
extra comparison on every render. Applying one and not the other is the most common way this
optimisation is wasted — and it looks like it should be working, which is what makes it hard to
spot.
Same reasoning for style objects: StyleSheet.create at module level is stable; an
inline style={{ padding: 8 }} is a new object every render.
Do not reach for it by default
Every memo costs a prop comparison, and every useCallback costs a
dependency check. Use them where components are numerous, expensive, or both — list rows, mainly.
Sprinkling them everywhere makes the code harder to read and measurably slower.
React 19's compiler changes this calculus: where it is enabled it inserts memoisation for you,
and hand-written useCallback becomes redundant. Worth knowing before you spend a day
adding them by hand.
Lists
Covered in lesson 7 and it belongs on any performance list. FlatList over
ScrollView; getItemLayout when rows are a fixed height; never a
FlatList inside a ScrollView in the same direction, which disables
virtualisation entirely.
Animations
useNativeDriver: true moves an animation to the UI thread, where a busy JavaScript
thread cannot stutter it. Only transform and opacity qualify — which is
why animating a layout property is the slow choice and a transform that looks the same is the fast
one. Lesson 10 covers it.
Context re-renders
A context value that changes re-renders every consumer. Two habits: split contexts by how often they change, and memoise the value so the provider re-rendering does not by itself invalidate everyone.
The demo app splits auth, menu, cart and toasts precisely so a cart change does not re-render everything that only cares about the signed-in user.
Images
The most common cause of memory pressure. A 4000×3000 photo rendered into a 100dp thumbnail
still decodes at full size — resize on the server, or use a CDN that does. expo-image
adds caching, better decoding and placeholders, and is usually worth swapping in.
Startup time
The metric users actually notice, and the one no in-app profiler shows you.
Hermes is the biggest lever and is on by default now: it can pre-compile your JavaScript to bytecode, so startup skips parsing entirely. Ship less JavaScript — route-level code splitting matters here as on the web. Do not block the first render on a network call; render the shell and fill it in.
The demo app holds its splash screen until it knows whether anyone is signed in, which is a deliberate trade: a slightly longer splash instead of a visible flash from signed-out to signed-in. Holding it for anything slower than a keychain read would be the wrong call.
Measure, do not guess
React DevTools Profiler shows which components rendered and why — start here, because "why did this re-render" is the question you usually have.
The performance monitor in the dev menu shows both frame rates. If the UI thread holds 60 and the JS thread drops, the problem is your code; if the UI thread drops, it is usually rendering or images.
Always profile a release build. Development has StrictMode double-rendering, unminified code and no bytecode precompilation. A debug build is dramatically slower than what you ship, and optimising against it means optimising the wrong thing.
What is next
Error Boundaries and Failure States — a blank screen is the one thing a user cannot refresh away.