The Problem
About six weeks into building Spindare's social feed, I started noticing something wrong. The feed would load fine — scroll fast, render smoothly — but after about 90 seconds of continuous use, frame rate would drop from 60fps to under 20. After three minutes, the app crashed entirely on lower-end iPhones.
The feed was using a standard FlatList with a real-time Supabase Realtime subscription pushing new posts. On the surface, everything looked fine. Under the hood, something was eating memory alive.
Diagnosing with Flipper
First step: open Flipper and watch the memory profile while scrolling. Within 60 seconds I could see it: memory climbing steadily with no GC cycles cleaning it up. The heap was growing ~4MB per minute on a feed with 1000 posts.
The two usual suspects for FlatList memory leaks are:
1. Unregistered event listeners — listeners added inside renderItem that never get cleaned up
2. Image caching — images loading into memory and never releasing
In our case it was both, plus a third thing I didn't expect.
Root Cause #1: Inline handlers in renderItem
Our renderItem was creating new function instances on every render:
renderItem={({ item }) => (
<FeedPost
post={item}
onLike={() => handleLike(item.id)}
onComment={() => openComments(item.id)}
/>
)}Every scroll event re-renders visible cells. Each re-render creates two new closures per post. With 1000 posts and rapid scrolling, that's thousands of closures never getting freed.
Fix: useCallback with stable references, and pass item.id as a prop instead of capturing it in a closure:
const handleLike = useCallback((id: string) => {
// handler logic
}, []);
renderItem={({ item }) => (
<FeedPost
post={item}
postId={item.id}
onLike={handleLike}
onComment={handleComment}
/>
)}Root Cause #2: Supabase channel subscription inside renderItem
This was the worse one. We had a component that subscribed to real-time updates for each individual post (for live like counts). The subscription was being created in useEffect but only cleaned up when the component unmounted — and FlatList's virtualization was unmounting and remounting cells constantly.
useEffect(() => {
const channel = supabase.channel(`post:${postId}`)
.on('postgres_changes', { ... }, handleUpdate)
.subscribe();
// missing return () => supabase.removeChannel(channel)
}, [postId]);Missing the cleanup function meant every virtualized unmount left a dangling Supabase WebSocket subscription. With 1000 posts in the feed, we could have hundreds of open channels simultaneously.
Fix: Always return the cleanup from useEffect:
useEffect(() => {
const channel = supabase.channel(`post:${postId}`)
.on('postgres_changes', { ... }, handleUpdate)
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [postId]);Root Cause #3: keyExtractor returning index
This one is subtle. We had:
keyExtractor={(item, index) => index.toString()}When new posts arrive at the top of the feed (real-time), all existing keys shift. React Native's reconciler thinks every item changed and re-renders the entire list. Use a stable ID:
keyExtractor={(item) => item.id}The Result
After all three fixes, memory growth dropped from ~4MB/min to essentially flat. The feed now runs at 60fps for 10+ minutes of continuous use with no crashes, even on iPhone 12 Mini with limited RAM.
The lesson: FlatList memory issues are almost always about three things — stable keys, cleanup in useEffect, and avoiding inline function creation in renderItem. Get those right and you rarely have to look further.