Almost every React Native perf guide quietly assumes you're holding a flagship phone. Here's what actually moves the needle once you test on a 2GB-RAM Android that real people are using.
Here's a reality check that took me way too long to internalize: a huge chunk of your users are on cheap Android phones. We're talking 2GB of RAM, sluggish storage, and a CPU that throttles the second things get warm. The app that feels buttery on your iPhone 15 can feel completely broken in their hands — and they'll just uninstall, no feedback, no bug report.
After shipping into a few markets like this, I stopped guessing and built myself a little checklist. None of it is fancy. But it consistently turns 'this app is unusable' into 'this is actually smooth,' so let me walk you through it.
1. Your lists are probably the problem
Nine times out of ten, the jank is in a list. Plain old FlatList with a sensible windowSize, removeClippedSubviews turned on, and stable keys will already get you most of the way there. It's not glamorous, but it works.
If you're rendering a big feed though, do yourself a favor and reach for FlashList from Shopify. It recycles views instead of holding everything in memory, and on a low-end device that difference is the gap between 'scrolls fine' and 'crashes on a fast flick.'
// FlashList: recycles rows instead of keeping them all in memory.
import { FlashList } from "@shopify/flash-list";
<FlashList
data={posts}
keyExtractor={(item) => item.id} // stable keys, not the index
estimatedItemSize={96} // lets it recycle aggressively
removeClippedSubviews // drop off-screen views
renderItem={({ item }) => <PostRow post={item} />}
/>;2. Stop hogging the JS thread
On a cheap phone, every heavy thing you do on the JS thread shows up immediately as dropped frames. That giant JSON parse, that image you're decoding inline, that handler firing on every keystroke — all of it stacks up.
So move the heavy lifting off the critical path, debounce the expensive stuff, and lazy-load screens so your startup bundle stays lean. The goal is simple: keep the thread free enough that the UI can actually breathe.
// Let the tap animation finish before doing expensive work.
import { InteractionManager } from "react-native";
const onPress = () => {
InteractionManager.runAfterInteractions(() => {
const parsed = JSON.parse(hugePayload); // off the critical path
setRows(parsed);
});
};
// Lazy-load heavy screens so they're not in the startup bundle.
const Analytics = lazy(() => import("./screens/Analytics"));3. Be honestly ruthless with images
I'll say it plainly — uncached, full-resolution images are the number one thing I see torching memory and triggering out-of-memory crashes on low-end hardware. It's almost always images.
Serve images at the size you're actually displaying them, cache them hard, and lean on WebP. That one habit alone has fixed more 'random crash' reports for me than any clever code optimization ever did.
import { Image } from "expo-image";
<Image
source={{ uri: `${cdn}/avatar.webp?w=96&q=70` }} // sized + WebP
style={{ width: 48, height: 48 }} // 2x the display size
cachePolicy="memory-disk" // don't refetch
contentFit="cover"
/>;Measure on the real thing, not the simulator
Last thing, and I can't stress this enough: profile on an actual cheap device. Not the simulator, not your fancy dev phone — the $80 Android your users actually bought.
Every single time I do this, the real bottleneck turns out to be somewhere I never would have guessed. The simulator lies to you in the most comfortable way possible, and comfort is exactly what you can't afford here.
