FlatList with image-heavy rows on a low-end Android phone
A field reporting app we maintain lets warehouse staff photograph incoming shipments. The list screen shows forty rows, each with a 120×120 thumbnail, a reference number, and a timestamp. On our office Reddy 7A — 2GB RAM, Android 9 — scrolling through that list dropped to roughly 14 frames per second after the first ten rows. The client's field tablets behaved the same way.
The first instinct was to blame the network. The images load from a CDN, and we assumed decode work was piling up. But the FPS drop happened even when we pre-cached all forty images to local storage. The bottleneck was not the network. It was the image decode and the way React Native's image component handles resizing.
The thumbnails were stored as 1200×900 JPEGs served by the CDN at that resolution. React Native's Image component decodes the full bitmap into memory, then scales it down to the 120×120 display size. On a 2GB device, that means forty 3.4MB bitmaps competing for memory. The garbage collector thrashes, the JS thread stalls, and the frame rate collapses.
We tried four things, in order, measuring each with the React Native Performance Monitor overlay (shake menu → Show Perf Monitor). We scrolled the list at a steady rate for ten seconds and read the average JS and UI thread FPS.
The baseline gave 14 FPS on the UI thread. Adding `getItemLayout` to skip measurement improved nothing — the layout was not the problem. The real fix came from requesting correctly sized images from the CDN. We added a query parameter to the image URL so the CDN serves a 240×180 version instead of the full 1200×900. That alone took us to 38 FPS.
The second improvement was `removeClippedSubviews`, which is specifically useful on Android. It keeps off-screen row views detached from the native hierarchy. We gained another 6 FPS, bringing the average to 44.
We also set `maxToRenderPerBatch` to 2 and `windowSize` to 5. The defaults are 8 and 21. On a fast phone you never notice the defaults cause extra work. On a 2GB device, reducing them means fewer rows decoded at once. The tradeoff is visible blank space during fast scrolls. We accepted that because the alternative was a list nobody could use.
The final change was `fadeDuration={0}` on the Image component. The default fade-in animation on Android triggers a re-render per image as it loads from cache. Disabling it removed a noticeable stutter.
<FlatList
data={shipments}
keyExtractor={(item) => item.id}
renderItem={renderRow}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
removeClippedSubviews
maxToRenderPerBatch={2}
windowSize={5}
/>The row component uses a plain Image with the CDN-resized URL and no fade:
const Thumb = ({uri}) => (
<Image
source={{ uri: `${uri}?w=240&h=180` }}
style={styles.thumb}
fadeDuration={0}
/>
);We did not switch to `react-native-fast-image`. It adds a native dependency and its own cache layer, and for forty cached thumbnails the gain was under 2 FPS in our test. The CDN resize plus the FlatList tuning above solved the problem without the extra library.
The client shipped the updated list to their field team three weeks ago. The warehouse staff reported that scrolling no longer stalls halfway down. The Reddy 7A holds 44 FPS through the full list, which is below 60 but smooth enough that nobody complains.