Why FlatList's onEndReached Fires Multiple Times — and How to Stop It
You finish wiring up infinite scroll, open the API log to double-check, and see "fetch page 2", "fetch page 3", "fetch page 3", "fetch page 3" stacked on top of each other. If you ship the Rork-generated FlatList code as-is to a real device, onEndReached is very likely to fire several times during the initial render and again when the screen comes back into focus. Left alone, this duplicates server records and burns through free API quotas surprisingly fast.
As an indie developer running a portfolio of wallpaper apps that has accumulated around 50 million downloads since 2014, I hit exactly this problem when I migrated a thumbnail grid from FlatList to FlashList. The list interleaves AdMob native ads every eight items, so each spurious load also inflated duplicated impressions — a problem that hurts revenue as much as it hurts the server bill.
Why onEndReached is not a scroll event
onEndReached is triggered by _maybeCallOnEndReached inside React Native whenever the bottom of the visible window falls within onEndReachedThreshold. The trap is that the FlatList considers itself "near the end" in three very common situations.
First, on the initial render when the data fits within the viewport. The last cell is already on screen, so onEndReached fires immediately. Second, right after a re-render where the list height changes. Even if the user has not moved, the recalculation can fire again. Third, when the screen is restored from a tab switch or a back navigation. If the scroll position was already near the bottom, the event fires once more.
In other words, onEndReached is not "the user scrolled to the bottom." It is "the last cell is now in the viewport." Writing a direct loadNextPage() call inside it without any guard is what causes the runaway requests.
The lightest fix: an onMomentumScrollBegin gate
The simplest and most reliable defense is to ignore onEndReached until the user has actually started a momentum scroll. onMomentumScrollBegin only fires for real, finger-driven scrolling, so it is the perfect signal that the user is genuinely paging through the list.
import { FlatList } from 'react-native';
import { useCallback, useRef } from 'react';
export function PostListScreen({ posts, loadNext }: Props) {
const onEndReachedCalledDuringMomentum = useRef(true);
const handleEndReached = useCallback(() => {
if (onEndReachedCalledDuringMomentum.current) return;
onEndReachedCalledDuringMomentum.current = true;
loadNext();
}, [loadNext]);
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
onMomentumScrollBegin={() => {
onEndReachedCalledDuringMomentum.current = false;
}}
/>
);
}The key detail is initializing the ref to true. That way the very first auto-fire on mount is ignored, and the flag only clears when the user has put a finger to the screen.
Add a duplicate-cursor guard inside the fetcher
A UI-side gate alone misses the case where momentum scrolling re-triggers the event right after the flag clears. Adding a guard inside the fetch function — refusing the same cursor twice — gives you a safety net that does not depend on the UI behaving perfectly.
import { useRef, useCallback, useState } from 'react';
export function usePaginatedPosts() {
const [posts, setPosts] = useState<Post[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const inflightCursor = useRef<string | null | undefined>(undefined);
const loadNext = useCallback(async () => {
// Bail out if a request for the same cursor is already in flight
if (inflightCursor.current === cursor) return;
inflightCursor.current = cursor;
try {
const res = await fetch(`/api/posts?cursor=${cursor ?? ''}`);
const data = await res.json();
setPosts((prev) => [...prev, ...data.items]);
setCursor(data.nextCursor);
} finally {
if (inflightCursor.current === cursor) {
inflightCursor.current = undefined;
}
}
}, [cursor]);
return { posts, loadNext };
}For a page-number API, swap the cursor check for "next page number is already in flight" — the principle is the same. Either way, the fetcher refuses to send a redundant request even if the UI hands it one.
Notes when you are using TanStack Query or SWR
If you are using TanStack Query's useInfiniteQuery, fetchNextPage already deduplicates inflight calls internally. When duplicate requests still slip through, the cause is almost always forgetting to check hasNextPage.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
const handleEndReached = useCallback(() => {
if (!hasNextPage || isFetchingNextPage) return;
fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);Both hasNextPage and isFetchingNextPage matter. Without the second guard, a slow network still lets a second request leak out.
Gotchas when migrating to FlashList
Moving to Shopify's FlashList for performance changes the timing of onEndReached a little. FlashList uses recycling and an estimated item size to prefetch, so leaving onEndReachedThreshold at the default 0.5 fires it earlier than FlatList. In my apps, dropping it to 0.3 and tuning estimatedItemSize to a value close to the real measurement was enough to remove the surprise fires.
Diagnosing the issue from logs alone
To verify a fix, drop a timestamped log line into onEndReached:
const handleEndReached = () => {
console.log('[onEndReached]', { cursor, now: Date.now() });
// ...
};If two calls land within 100 ms of each other, the cause is almost certainly a render-driven re-fire. If they are 500 ms apart, the user is probably pausing and resuming a scroll, or your flag is being cleared too early.
A four-step pre-ship checklist
Before pushing the change, walk through these four checks on a real device. They take less than two minutes and they catch almost every infinite-scroll regression I have seen.
- Open the list while the initial data is shorter than the viewport, and confirm
loadNextdid not fire on mount. - Pull to refresh, and confirm
loadNextdid not fire as a side effect. - Switch tabs and come back, and confirm
loadNextdid not fire on focus restore. - Fling the list so it sticks to the bottom, and confirm only one request goes out for any given cursor.
The third one matters for monetized lists in particular — it is the easiest way to keep AdMob impression counts honest.
Infinite scroll is one of those features that looks like it works while quietly hammering your backend underneath. Once you understand that onEndReached is render-driven and add defenses on both the UI side and the fetcher side, you stop being the developer who finds out about a runaway loop from a billing alert at 2 a.m. Thanks for reading.