Diagnosing and Fixing FlatList Scroll Jank in React Native
A step‑by‑step diagnostic guide for React Native FlatList scroll jank: identify missing keys, absent getItemLayout, and inline callbacks; apply targeted fixes; verify with PerfMonitor and React DevTools; and know when to escalate to FlashList or native modules.
17 Jan 2026, 09:26 UTC

Recognizable Condition
While scrolling a FlatList with a moderate‑to‑large data set (hundreds of items), the UI drops frames, feels “janky,” or the React Native performance monitor reports < 55 fps during scroll gestures.
Cause / Diagnostic Table
| Observed Symptom | Likely Root Cause | Quick Test |
|---|---|---|
| Frame drops on every scroll tick | Missing or non‑unique keyExtractor causing full list re‑reconciliation | Replace with item => item.id and watch fps |
| Layout thrashing on each render | Absent getItemLayout for fixed‑height rows | Add getItemLayout={(data, index) => ({length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index})} |
| Rows re‑render even when data unchanged | Inline renderItem / keyExtractor functions create new references each parent render | Wrap with useCallback and verify via React DevTools “Highlight updates” |
| Performance still poor after above fixes | Expensive child components, heavy image decoding, or native bridge overload | Profile with Flipper → React DevTools → Profiler |
Ordered Diagnostic Checks
- Enable the performance monitor. On a connected Android device/emulator run:
adb shell setprop debug.react.perf 1 # then restart the app adb shell am force-stop com.yourpackage adb shell am start -n com.yourpackage/.MainActivityWhere to run: terminal with
adbin PATH. Permissions: USB debugging enabled, device authorized. Risk: the flag adds overhead; disable after testing (adb shell setprop debug.react.perf 0). - Confirm frame‑rate drop. Open the in‑app PerfMonitor (shake device → “Show Perf Monitor”) or Flipper’s “Performance” tab. Scroll the list and note the fps value. Target: ≥ 55 fps sustained.
- Validate
keyExtractoruniqueness. Search the codebase forkeyExtractor. If it uses the array index ((_, i) => i.toString()) or is missing, replace with a stable ID from your data model.// Bad keyExtractor={(_, i) => i.toString()} // Good keyExtractor={item => item.id} - Add
getItemLayoutwhen row height is constant. Measure the row height once (e.g., viaonLayouton a sample item) and supply the layout function.const ITEM_HEIGHT = 72; // px, measured once const getItemLayout = (data, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, }); <FlatList data={items} renderItem={renderItem} keyExtractor={item => item.id} getItemLayout={getItemLayout} /> - Eliminate inline function definitions. Move
renderItemandkeyExtractoroutside the component or memoize withuseCallback.const renderItem = useCallback(({item}) => ( <ItemComponent title={item.title} /> ), []); // deps only if ItemComponent props change const keyExtractor = useCallback(item => item.id, []); - Verify reduced re‑renders. In React DevTools (Flipper → React DevTools), enable “Highlight updates when components render.” Scroll again; only the visible window should flash.
Fixes Tied to Findings
| Finding | Fix | Verification |
|---|---|---|
| Non‑unique keys | Use stable item.id in keyExtractor | PerfMonitor fps ↑ 10‑20 % |
Missing getItemLayout | Implement constant‑height layout function | Layout passes drop from O(n) to O(1) per frame |
| Inline callbacks | Memoize with useCallback (or define outside) | DevTools “Highlight updates” shows fewer row flashes |
| Heavy child components | Virtualize images (FastImage), memoize children, or move work to a native module | Profiler shows ItemComponent render time < 2 ms |
Escalation Criteria
- After applying all three primary fixes (unique keys,
getItemLayout, memoized callbacks) the scroll fps remains ≤ 50 fps on a mid‑range device (e.g., Snapdragon 765G). - Profiling reveals > 30 % of frame time spent in native bridge (e.g., large image decoding). Consider:
- Switching to
FlashList(Shopify) which uses a more aggressive recycling strategy. - Offloading image loading to a native module (e.g.,
react-native-fast-image). - Paginating data and using
onEndReachedto keep the list size bounded.
- Switching to
Concrete Before/After Example
// BEFORE – janky
function Feed({posts}) {
return (
<FlatList
data={posts}
renderItem={({item}) => <PostCard post={item} />}
keyExtractor={(_, i) => i.toString()}
/>
);
}
// AFTER – optimized
const POST_HEIGHT = 240;
const getItemLayout = (_, index) => ({
length: POST_HEIGHT,
offset: POST_HEIGHT * index,
index,
});
const renderItem = useCallback(({item}) => (
<PostCard post={item} />
), []);
const keyExtractor = useCallback(item => item.id, []);
function Feed({posts}) {
return (
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
initialNumToRender={10}
maxToRenderPerBatch={5}
windowSize={5}
/>
);
}
Run the adb perf flag, scroll both versions, and compare the PerfMonitor numbers. Expect the “AFTER” version to stay above 55 fps on the same device.
Limitations & Practical Check
getItemLayoutonly works when every row has the exact same height. Dynamic heights require measurement or a different virtualization library.- Unique keys must be stable across re‑orders; using array index breaks state when items are inserted/removed.
- Memoization with empty dependency arrays assumes the rendered component does not close over changing props. Add dependencies if
PostCardreceives new callbacks.
Quick verification script (run in Metro console):
// Paste into the Metro REPL (Ctrl+M → "Open Debugger")
const {PerformanceMonitor} = require('react-native');
PerformanceMonitor.start();
setTimeout(() => {
const stats = PerformanceMonitor.stop();
console.log('Avg FPS:', stats.averageFPS);
}, 10000); // scroll for 10 s
If the printed averageFPS is ≥ 55, the primary fixes are sufficient. Otherwise, follow the escalation path.
Summary Checklist
- ☐ PerfMonitor enabled & fps baseline captured
- ☐
keyExtractoruses stable unique IDs - ☐
getItemLayoutsupplied for fixed‑height rows - ☐
renderItemandkeyExtractormemoized - ☐ DevTools “Highlight updates” shows minimal row flashes
- ☐ Fps ≥ 55 fps sustained → done; else → evaluate FlashList / native image loader
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.