“The app is slow” is a complaint, not a diagnosis. Under it sit at least three unrelated problems, and the fixes for them share almost nothing:
- The app takes too long to become usable after launch.
- It drops frames while scrolling or animating.
- It responds late to a tap.
Optimising the wrong one is how teams spend a fortnight memoising components and ship an app that still takes four seconds to open. What follows is the order we work in — measurement first, cheap causes second, symptom-specific fixes last.
Measure a release build, on a real device
This is the single most common mistake, and it invalidates everything that comes after it. A debug build runs unoptimised JavaScript, executes development-only warnings and checks, and connects to a packager. Timings taken from it do not describe what your users experience.
Test a release build, on hardware. Preferably a mid-range Android phone a few years old rather than the newest iPhone — that is where the problems are visible, and it is closer to what a lot of your users actually hold.
Find out which thread is busy
This is the most useful idea in React Native performance work, and the one most often skipped. Two threads matter:
- The JS thread runs your JavaScript — component render logic, state updates, data handling.
- The UI thread (the main/native thread) does layout, drawing and image decoding.
Open the Perf Monitor from the dev menu and watch both frame rates while you reproduce the problem. The answer tells you where to look:
- JS drops, UI is fine — the problem is your JavaScript. Re-renders, expensive work in render, or a blocked event loop.
- UI drops, JS is fine — the problem is rendering. Deep view hierarchies, oversized images, expensive shadows or blending.
- Both look fine but it feels slow — usually startup cost, or work happening between a tap and the state update that responds to it.
For anything beyond that, the React DevTools profiler shows which components re-render and why, and the platform profilers — Android Studio’s and Xcode Instruments — show what the native side is doing.
Rule out the cheap causes
Confirm Hermes is on
Hermes is the JavaScript engine built for React Native. It compiles your JavaScript to bytecode ahead of time, so the app does not parse and compile source at launch, and it generally uses less memory. It is the default in current React Native versions, but older projects may still be running JavaScriptCore — check rather than assume, because switching is usually the largest startup improvement available for one configuration change.
Strip console calls from release builds
Console calls are not free in production. They serialise their arguments and write to the native log, and one left inside a list row or a render path runs far more often than whoever added it expected.
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};Keep the plugin scoped to production so your development logs survive.
Symptom: the app is slow to start
Startup time is mostly the cost of evaluating JavaScript modules before the first screen can render. Two things drive it: how much code has to be evaluated, and how much work that code does while being evaluated.
Watch for work at module scope. Anything at the top level of a file runs the moment that file is imported — creating a client, reading storage, building a large constant. Move it inside a function so it runs when it is needed instead of at launch.
Defer modules you do not need immediately. Metro can rewrite imports so a module is only evaluated on first use, which keeps code paths a user may never visit out of your startup path. Recent React Native templates enable inline requires by default — check your Metro configuration for what yours does.
Do not block the first render on the network. Render the screen, then fetch. An app that shows a usable frame quickly is perceived as fast even when the data arrives at the same moment it otherwise would have.
Symptom: lists drop frames while scrolling
Lists are where most React Native performance problems live, because a list multiplies every inefficiency in a row component by the number of rows.
Use a virtualised list
A ScrollView mounts every child immediately. A FlatList renders roughly what fits on screen and recycles as you scroll. If the list has a fixed handful of items, a ScrollView is fine. If it can grow with your data, it should be a FlatList.
Stop the whole list re-rendering
This is the most common list bug we see, and it is easy to miss because the code looks reasonable:
// Every parent render creates a new renderItem function and
// a new onPress closure, so every visible row re-renders.
<FlatList
data={items}
renderItem={({ item }) => (
<Row item={item} onPress={() => openItem(item.id)} />
)}
/>The fix is to make the identities stable and memoise the row:
const Row = React.memo(function Row({ item, onPress }) {
return (
<Pressable onPress={() => onPress(item.id)}>
<Text>{item.title}</Text>
</Pressable>
);
});
const openItem = useCallback((id) => navigation.navigate('Item', { id }), [navigation]);
const renderItem = useCallback(
({ item }) => <Row item={item} onPress={openItem} />,
[openItem],
);
const keyExtractor = useCallback((item) => item.id, []);
<FlatList data={items} renderItem={renderItem} keyExtractor={keyExtractor} />;Use a stable identifier in keyExtractor, not the array index. An index key makes React reuse the wrong row whenever the list is reordered or filtered, which shows up as flickering content and occasionally as the wrong item being tapped.
Tell the list your row height, if you know it
When rows are a known fixed height, getItemLayout lets the list skip measuring them:
const ITEM_HEIGHT = 72;
const getItemLayout = (_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});Only do this if the height really is fixed. If rows vary — wrapping text, optional images — the values will be wrong and you will get misplaced scroll positions and jumping content, which is a worse problem than the one you were solving.
Look at what is inside the row
A row is rendered many times, so its cost is multiplied. Flatten nested views that exist only for layout, and check your images: a three-thousand-pixel photograph decoded into an eighty-pixel thumbnail costs memory and UI-thread time on every row. Resize server-side and send the size you will display.
If a list is still struggling after all of this, FlashList is a drop-in-ish alternative with a different recycling strategy, and is worth measuring against your own data.
Symptom: animations stutter
An animation driven from JavaScript has to send a value across to the native side on every frame, so anything blocking the JS thread stalls it. The fix is to move the animation off that thread entirely.
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();With useNativeDriver, the animation runs on the UI thread and keeps going even while JavaScript is busy. The constraint is that it supports transform and opacity — not layout properties such as width, height, top or left. Most layout animations can be expressed as translateX, translateY or scale instead, and should be.
For gesture-driven animation, where a dropped frame is immediately obvious under the finger, Reanimated runs animation logic on the UI thread rather than sending values to it, and is the better tool.
Symptom: taps feel laggy
If the UI thread is healthy and a tap still feels late, something is occupying the JS thread between the event and the state update that answers it.
Re-render storms. Profile the interaction in React DevTools and look at what re-renders. A frequent cause is context: every consumer of a context re-renders when any part of its value changes, so a single context holding user, theme and cart will re-render most of the app on any of them. Split contexts by how often their contents change.
Expensive synchronous work in the handler. Sorting a large array or parsing a large response inside an onPress blocks everything until it finishes. Let the visual response happen first and run the work afterwards — InteractionManager exists for exactly this — or move it off the JS thread altogether.
When JavaScript optimisation is not the answer
Some work does not belong on the JS thread at any level of optimisation: image or video processing, cryptography, large file operations, continuous sensor or Bluetooth streams. The correct fix is a native module that does the work on a native thread in Swift or Kotlin and returns a result.
This is a large part of what we do, so read that as an interested opinion — but the reasoning stands on its own, and the boundary is usually obvious once you have profiled: if the JS thread is saturated by one operation and that operation is not React work, it should not be in JavaScript.
React Native’s New Architecture makes this cheaper, replacing the old asynchronous bridge with a direct interface between JavaScript and native code. It is the default in recent versions; if you are on an older one, that is worth knowing before you plan a migration.
What we would not do
- Memoise reflexively.
useMemoanduseCallbackare not free — they cost a comparison and a cache entry, and they add dependency arrays that go stale. Wrapping a cheap calculation is a net loss. Use them where a profile shows the re-render is real, and around values passed to memoised children. - Optimise before measuring. Intuition about which line is slow is wrong often enough that it is not worth acting on.
- Judge performance on a flagship device. The newest phone hides exactly the problems you are looking for.
- Rewrite in native because one screen is slow. That is a very expensive answer to a question that usually has a cheaper one.
The order, summarised
- Reproduce on a release build, on a real mid-range device.
- Check the Perf Monitor to see which thread is dropping frames.
- Confirm Hermes is on and console calls are stripped.
- Fix by symptom: startup work, list re-renders, native-driven animation, or blocked handlers.
- Measure again, on the same device and the same build type.
- Only then consider moving work to a native module — and only for work that should never have been in JavaScript.
None of this is exotic. Most React Native performance problems are a handful of ordinary mistakes repeated across a codebase, and the difference between an app that feels fast and one that does not is usually diagnosis rather than cleverness.
If you have an app that is slow and you would rather not spend a month finding out why, this is the kind of work we do — see React Native development or how we work.