An infinite feed built with TanStack Query v5's useInfiniteQuery calls fetchNextPage from an IntersectionObserver sentinel, and the network tab shows repeated and cancelled page requests; why, and how do you fix it?
answer
- the observer fires more than you think
- one fetch per infinite query
- the default cancels the running fetch
- same last page, same param
- guard on isFetching, re-check after
basics
~20 sRepeated sentinel callbacks call fetchNextPage while a fetch is running, and its default cancelRefetch: true cancels that fetch and starts another for the same page. Guard the call with hasNextPage && !isFetching, or pass { cancelRefetch: false }.
solid answer
~50 sAn IntersectionObserver fires on every visibility change, and it fires even more often when it is re-created on each render. Each `fetchNextPage()` call defaults to `cancelRefetch: true`: if a fetch for that infinite query is already running and data exists, TanStack Query silently cancels it and starts a new one. The pages have not changed yet, so `getNextPageParam` returns the same param, and you see the same page cancelled and requested again. If the running fetch was a background refetch of the whole list, that refresh is thrown away. An infinite query has one cache entry, so only one fetch should run at a time. Guard the call with `hasNextPage && !isFetching`, or pass `{ cancelRefetch: false }` so extra calls just return the running fetch. Drive it from an effect that re-checks when `isFetching` turns false, so a sentinel that is still visible loads the next page.
code
tsx · 64 linesimport { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
type FeedPage = { items: { id: string; text: string }[]; nextCursor?: string }
async function fetchFeed({
pageParam,
signal,
}: {
pageParam: string
signal: AbortSignal
}): Promise<FeedPage> {
const res = await fetch(`/api/feed?cursor=${encodeURIComponent(pageParam)}`, { signal })
if (!res.ok) throw new Error('Failed to load feed')
return res.json()
}
export function Feed() {
const sentinelRef = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// One observer for the component's lifetime; it only records visibility.
useEffect(() => {
const el = sentinelRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ rootMargin: '400px' },
)
observer.observe(el)
return () => observer.disconnect()
}, [])
// Re-evaluated when visibility or fetch state changes, so a sentinel that
// is still visible after a short page triggers the next page.
useEffect(() => {
if (inView && hasNextPage && !isFetching) {
void fetchNextPage()
}
}, [inView, hasNextPage, isFetching, fetchNextPage])
return (
<>
<ul>
{data?.pages
.flatMap((page) => page.items)
.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
<div ref={sentinelRef} />
{isFetchingNextPage ? <p>Loading more…</p> : null}
{data && !hasNextPage ? <p>You are all caught up.</p> : null}
</>
)
}go deeper
Know that fetchNextPage should only be called when hasNextPage is true and nothing is already loading, and that an intersection observer can fire many times.
Explain that an infinite query has one cache entry and one fetch at a time, and that fetchNextPage cancels a running fetch by default through cancelRefetch.
Diagnose from the network tab: repeated requests for the same cursor, lost focus refetches, a stuck feed. Fix with an isFetching guard plus an effect that re-checks after each fetch.
Decide whether infinite scroll or an explicit Load more button suits the product, weighing accidental over-fetching, accessibility and server load against engagement.
## What the sentinel pattern does A common way to build infinite scroll is to render an empty **sentinel** element after the last item and watch it with an `IntersectionObserver`. When the sentinel scrolls into view, the callback calls `fetchNextPage()` from TanStack Query's `useInfiniteQuery`. The observer's callback fires when the element's intersection with the viewport **changes**: entering, leaving, or crossing a threshold. In practice it fires more often than people expect: - Every time the component re-creates the observer, for example inside an effect whose dependencies change on each render, the new observer reports the current state immediately. - A short page can leave the sentinel in view after it renders, and layout shifts from images loading can move it in and out. - A generous `rootMargin` makes the sentinel "visible" well before the user reaches the end. ## Why requests repeat: cancelRefetch defaults to true All pages of an infinite query live in **one cache entry**, so the library allows **one fetch at a time** for it. The docs warn that two simultaneous fetches could overwrite each other's data. What happens on a second call depends on the `cancelRefetch` option of `fetchNextPage`, which **defaults to `true`**: 1. The first callback calls `fetchNextPage()`. The library computes the param with `getNextPageParam` from the current last page and starts the request. 2. Before it resolves, another callback calls `fetchNextPage()` again. 3. Because a fetch is running and the query already has data, the default `cancelRefetch: true` makes the library **silently cancel** the running fetch and ignore its result. If your `queryFn` passes the provided `AbortSignal` to `fetch`, the network tab shows the request as cancelled. If it does not, the response still arrives and is simply discarded. 4. The new fetch recomputes the param from the **same** last page, so it requests the **same** page again. Rapid callbacks therefore turn one page load into several, with the page arriving later than it would have. ## Why background refreshes get lost The running fetch is not always a next-page fetch. It may be a **background refetch** of the whole list, triggered by invalidation or window focus, which re-requests every loaded page one after another. `isFetchingNextPage` is `false` during such a refetch. A guard of `!isFetchingNextPage` therefore lets `fetchNextPage()` through, and the default cancels the refresh. The list keeps its old data and the user never sees the update. This is the case the docs mean when they say that calling `fetchNextPage` during an ongoing fetch "runs the risk of overwriting data refreshes happening in the background". ## The fix | Guard | Stops repeated next-page calls | Protects a background refetch | Watch out for | |---|---|---|---| | `hasNextPage && !isFetchingNextPage` | yes | no | still cancels focus or invalidation refetches | | `hasNextPage && !isFetching` | yes | yes | needs a re-check once the fetch ends | | `fetchNextPage({ cancelRefetch: false })` | yes, extra calls return the running fetch | yes | a call made during a refetch loads no new page | The recommended shape combines these: - **Guard on `hasNextPage && !isFetching`**, which is what the docs recommend when the user does not trigger the call directly. - **Separate "is visible" from "should fetch".** Let the observer only record visibility in state, and make the fetch decision in an effect that depends on visibility, `hasNextPage` and `isFetching`. An observer fires only on changes. Without the effect, a call that was skipped while a fetch ran is never retried, and a still-visible sentinel leaves the feed stuck. - **Create the observer once** (an effect with an empty dependency list, disconnected on unmount) instead of on every render. - **Stop at the end.** When `hasNextPage` is false, render an "all caught up" message and never call `fetchNextPage`. ## Confirming the diagnosis Before changing code, confirm that the duplicates really are the same page. Log the `pageParam` your `queryFn` receives, or read the cursor in each request URL. Two or three requests carrying the **same cursor**, with all but the last cancelled or discarded, point to repeated `fetchNextPage()` calls against a running fetch. Requests carrying **different** cursors in quick succession point to something else, usually a guard that lets calls through after each page lands while the sentinel stays visible, which is the intended behaviour. ## Checking the fix In the network tab, scrolling steadily should now produce exactly one request per page, in order, with no cancelled duplicates. Triggering a window-focus refetch and then scrolling should show the refetch complete before the next page is requested. The library's Devtools panel, if you use it, shows a single fetch at a time on the feed's key.
- When would you choose fetchNextPage({ cancelRefetch: false }) over an !isFetching guard?When calls come from a place where you cannot easily read the query state, such as a list component's end-reached callback. With `cancelRefetch: false`, extra calls return the running fetch's promise instead of cancelling it, so nothing is lost. The catch is that a call made during a background refetch loads no new page, so something must trigger another attempt afterwards.
- Does the cancelled request actually stop on the network?Only if your `queryFn` passes the `signal` from its context to `fetch` (or to your HTTP client). TanStack Query then aborts the request when it cancels the fetch. If the signal is ignored, the request runs to completion and its result is discarded, so the server still does the work.
saying these in an interview costs you the question
- Calling fetchNextPage twice just queues a second page after the first.
- A guard on isFetchingNextPage alone also protects background refetches.
- Each fetchNextPage call requests a different page, so repeats are harmless.
- Re-creating the IntersectionObserver on every render is harmless.
- cancelRefetch: false makes every sentinel callback load a new page.