The feed was slowing to a halt after a few minutes of use. Here's how I tracked down the problem and fixed it.
Spindare's social feed worked perfectly during development. A few dozen mock posts, fast scrolls, clean renders. Then we got to a proper test build with real data (a few hundred posts, real images, real timestamps) and after about four minutes of scrolling the app would slow to a crawl. On older Android devices it would crash completely.
This is the story of how I tracked it down, why it took longer than it should have, and what I'd check first if it happened again.
Memory climbed steadily the longer the feed was open. JavaScript heap would sit at around 40 MB on launch and creep toward 200 MB after ten minutes. The performance monitor in Flipper made it obvious something was accumulating, but it wasn't obvious what.
I ran the standard checks first. No massive images being stored in state. No obvious re-renders from incorrect useCallback dependencies. Redux slices looked clean. The feed component itself was straightforward: a FlatList rendering PostCard components, each with an image, some text, and an interaction row.
My first assumption was the images. We were using react-native-fast-image with a memory cache, and with hundreds of posts each with a 600px-wide image, it seemed obvious. I added aggressive cache limits and switched some thumbnails to lower resolution. Memory still climbed.
Two hours wasted. The images were not the problem.
I added logging to the PostCard component's mount and unmount cycles. Posts were mounting when scrolled into view, but a significant fraction were never unmounting. I expected FlatList's virtualisation to handle this. It was, mostly. But something was keeping a reference alive on the JavaScript side even after the native view was recycled.
The culprit was in how we were handling real-time like counts. Each PostCard was subscribing to a Supabase realtime channel to get live updates for its specific post ID:
// PostCard.tsx: the broken version
useEffect(() => {
const channel = supabase
.channel(`post-likes-${post.id}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'posts',
filter: `id=eq.${post.id}`,
}, (payload) => {
setLikes(payload.new.like_count);
})
.subscribe();
}, []); // โ empty dependency array, no cleanupNo cleanup function. The channel was created when the card mounted, but never removed when the card unmounted. Worse, because the dependency array was empty, if a post re-rendered the old channel stayed open and a second one was created.
With hundreds of posts in the feed, and each visible post holding an open WebSocket subscription, the memory just kept climbing.
// PostCard.tsx: fixed
useEffect(() => {
const channel = supabase
.channel(`post-likes-${post.id}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'posts',
filter: `id=eq.${post.id}`,
}, (payload) => {
setLikes(payload.new.like_count);
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [post.id]); // โ correct dependencyAfter the fix, memory stabilised at around 55 MB regardless of how long the feed was open. The slow-down and crashes disappeared completely.
The real mistake wasn't missing the cleanup, it was subscribing per-card at all. For a feed with potentially thousands of posts, opening individual realtime channels for each one is always going to be expensive. The better approach is a single channel at the feed level that handles all post updates, and distributing the relevant updates down to the cards via React context or a local state manager.
We shipped the cleanup fix for the September launch since it solved the crash. The architectural refactor is on the roadmap for the post-launch iteration.
If your React Native FlatList is leaking memory, check your useEffect cleanup functions in list item components before anything else, especially if those components subscribe to anything (realtime, events, timers, or animation listeners).
Written by
Kristian Gjergji
Developer ยท Kosovo / Italy