Past 300 items, scrolling suddenly feels broken
When I shipped the first version of a journaling app generated with Rork, scrolling felt buttery during testing with sample data. Once real users hit 200 or 300 entries, however, scrolling visibly stuttered, and the timeline with image cards started flashing white placeholders for a frame as you swiped down.
My first guess was image caching. The actual culprit was that FlatList had hit its rendering ceiling. Swapping the same data into FlashList v2 made scrolling smooth again, and memory usage dropped noticeably. Below is how I think about moving a Rork-generated app to FlashList v2, along with the places where I got stuck.
Why FlatList slows down isn't visible from your code
FlatList is React Native's built-in list, but under the hood it inherits from VirtualizedList and works by unmounting items that scroll off-screen. The catch is that every mount triggers React's reconciliation and a layout pass on the JS thread.
If your card has an image, a Reanimated animation, and a few TouchableOpacity regions, mounting a single card eats a few milliseconds. Multiply that by twenty cards on screen and scrolling no longer keeps up with your finger.
FlashList, built by Shopify, solves this problem with cell recycling. Instead of unmounting items, it reuses existing cells and swaps in new data — the same pattern UIKit and RecyclerView use natively. v2 makes this even smarter, so you no longer need to pass estimatedItemSize; it measures the cells it renders at runtime and adjusts as it goes.
The biggest v2 change: you no longer write estimatedItemSize
Anyone who used FlashList v1 will remember estimatedItemSize as a required prop. It was a hint about the average item height for the first render, and getting it wrong caused empty space, scroll jumps, and other strange artifacts.
v2 no longer needs this prop, because it measures rendered cells itself (it is runtime measurement, not a compiler step). In my experience, the more you struggled with v1, the more v2 feels like a relief.
Check this before you migrate
v2 was rebuilt for the New Architecture (Fabric). A project still on the old architecture cannot use v2 and should stay on v1. Compare your Expo SDK and React Native versions against the official compatibility notes first. I once skipped that check, ran npx expo install, and ended up staring at a build error late at night.
Here is how the three options differ:
| Aspect | FlatList | FlashList v1 | FlashList v2 |
|---|---|---|---|
| Off-screen cells | Unmounted and discarded | Recycled | Recycled |
| Height estimation | Optional via getItemLayout | estimatedItemSize required | Not needed (measured at runtime) |
| Architecture | Either | Either | New Architecture expected |
| Resetting cell state | Not needed (fresh mount) | Handle it yourself | useRecyclingState available |
This reflects what I checked at the time of writing; please confirm the details in the official FlashList documentation.
Migration steps for a Rork-generated app
Rork templates lean on FlatList by default, so we start with installation:
# Run from your project root
npx expo install @shopify/flash-list
# If you also use Reanimated, keep it on the latest matching version
npx expo install react-native-reanimatedNext, replace the components that render FlatList. Here is a minimal before/after.
// Before — FlatList version
import { FlatList } from "react-native";
export function PostList({ posts }: { posts: Post[] }) {
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
// We tuned the offscreen window and initial render count,
// but the gains were limited
windowSize={10}
initialNumToRender={8}
removeClippedSubviews
/>
);
}// After — FlashList v2 version
import { FlashList } from "@shopify/flash-list";
export function PostList({ posts }: { posts: Post[] }) {
return (
<FlashList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
// estimatedItemSize is no longer needed in v2
// Even with image-heavy cards, long lists stay smooth
/>
);
}The point is that you can delete most of FlatList's tuning props. windowSize, initialNumToRender, removeClippedSubviews, getItemLayout — these FlatList tunings do not need to come along. I made the mistake of leaving old props in place during migration and missed the runtime warnings, so always glance at the console once after the swap.
Lists with variable-height cards stay reliable
FlashList v2 shows the biggest improvement on lists with mixed item heights — the kind of social timeline where text-only cards sit alongside image cards.
type FeedItem =
| { type: "text"; id: string; body: string }
| { type: "image"; id: string; uri: string; caption: string };
export function Timeline({ items }: { items: FeedItem[] }) {
return (
<FlashList<FeedItem>
data={items}
keyExtractor={(item) => item.id}
// getItemType massively improves cell recycling efficiency
getItemType={(item) => item.type}
renderItem={({ item }) => {
if (item.type === "image") return <ImageCard item={item} />;
return <TextCard item={item} />;
}}
/>
);
}getItemType exists in v1 too but remains essential in v2. With it, image cards and text cards live in separate recycle pools, and you avoid forcing differently shaped cells to swap contents. I skipped it the first time and saw a brief flicker in a mixed list; if your item types are clearly distinct, it is cheap insurance.
Three places people get stuck
Sharing the traps I personally fell into so you can skip them.
First: nesting inside a ScrollView. This applies to all VirtualizedList descendants, and FlashList is no exception. Wrap a FlashList in a <ScrollView> and virtualization disables itself, mounting every item, ending up slower than FlatList. When you need a scrolling parent layout, lean on ListHeaderComponent and ListFooterComponent instead. The companion piece Resolving the VirtualizedList nesting warning covers the broader context.
Second: putting a key on the row component. Out of FlatList habit, I once wrote key={item.id} on the element returned from renderItem. That makes the cell get rebuilt on every recycle and cancels the benefit. Leave identity to keyExtractor, and define it outside the component (const keyExtractor = (item: Post) => item.id) so it is not recreated each render.
Third: mutating the data array. FlashList watches the data reference to compute diffs. When sorting or filtering, always return a new array — setItems([...items, newItem]) — never items.push(newItem). FlatList behaves the same way, but FlashList's deeper internal optimization makes mutation bugs harder to spot visually. If you store the list in Zustand, Redux, or a similar store, double-check that your selectors return stable references between renders; otherwise the recycle pool will thrash and you'll lose the gains.
Working with Reanimated and gesture-driven cards
A common Rork-app pattern is a card that supports swipe actions, scale animations, or shared element transitions powered by Reanimated. With FlatList you often had to fight the list's re-render behavior to keep gesture state stable. With FlashList v2 you mostly get this for free, but two details matter.
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { FlashList } from "@shopify/flash-list";
function SwipeableCard({ post }: { post: Post }) {
const offset = useSharedValue(0);
const cardStyle = useAnimatedStyle(() => ({
transform: [{ translateX: withSpring(offset.value) }],
}));
// Reset shared values when this cell is recycled for a different item
// by keying the shared value initialization on post.id
// (useRecyclingState is another option instead of plain useState)
return <Animated.View style={cardStyle}>{/* card body */}</Animated.View>;
}The first detail is to initialize shared values per item, not per cell. When FlashList recycles a cell to render a different post, the component instance is preserved but the props change. If you keep gesture state in a ref, the recycled cell will inherit the previous card's swipe offset. The cleanest fix is to drive shared values from props derived from post.id, or reset them inside an effect that depends on post.id.
The second detail is to wrap heavy cards in React.memo. FlashList itself does not memoize your row component, so a parent re-render — for example, when a new item arrives at the top — can trigger renders for all visible cards. memo plus a stable keyExtractor cuts those wasted renders effectively.
Measure the improvement, don't trust the feeling
You don't have to rely on subjective impressions. The React Native Performance Monitor surfaces JS and UI thread frame rates in the top-right corner once you shake the device on a dev build and enable Show Perf Monitor.
// Rough timing helper for development builds (a guide, not a true FPS meter)
import { InteractionManager } from "react-native";
export function trackScrollPerformance(label: string) {
const start = Date.now();
return InteractionManager.runAfterInteractions(() => {
const elapsed = Date.now() - start;
if (__DEV__) {
console.log(`[perf] ${label}: ${elapsed}ms`);
}
return elapsed;
});
}On my own device, a 500-item image card list ran at about 35–40 fps with FlatList and settled around 58–60 fps after moving to FlashList v2. Numbers swing with the device and the weight of your cards, so treat this as one data point and measure before and after with the same gesture in your own app.
Migrate one component at a time, starting with the worst offender
The closing thought I want to leave you with is that you don't have to swap your whole app at once. Pick one heavy list, replace it, and let the numbers confirm it. I keep to that order in my own app, because I've been burned by changing everything at once and not knowing which change helped. Pick the single laggiest list, replace it, measure the win, then move to the next. That order makes every migration tangible.
If you're going to try one thing today, choose the longest-list screen in your app and apply just the minimal example above. The replacement takes under thirty minutes and you'll feel the difference the moment you reopen the app. If your cards rely on animation, pair it with a quick review of combining Reanimated 3 with Gesture Handler to push the experience further.