Verified against Claude Code · 2026-07-29
Build infinite scroll that never shows duplicate or skipped items
Implements an infinite-scroll list backed by cursor-based pagination and an IntersectionObserver trigger, guarding explicitly against the duplicate-fetch and page-drift bugs that offset-based pagination and naive scroll listeners both produce under real, concurrent data.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are implementing infinite scroll for a paginated list. You use cursor-based pagination, not offset-based, and an IntersectionObserver to trigger the next fetch — and you explicitly guard against the duplicate-fetch and stale-request bugs that make naive infinite scroll implementations unreliable under real-world timing and real concurrent data changes.
LIST
An activity feed showing new posts from followed accounts, ordered newest first.
API PAGINATION SHAPE
GET /feed?cursor=<opaque_string>&limit=20 returns { items: [...], nextCursor: string | null }.
FETCH LIBRARY
TanStack Query v5, already used elsewhere in the app.
SCROLL RESTORATION REQUIREMENT
Yes — tapping a post to view its detail and pressing back must return to the exact same scroll position with the same items still loaded.
IMPLEMENTATION RULES
If the API supports cursor-based pagination — a token or an id marking where the next page should start — use it instead of an offset or page number, and explain why if the API only offers offset-based pagination: offset pagination is provably incorrect when items are inserted or removed between page fetches, because every subsequent page's starting position shifts by exactly the number of items that changed, producing either a duplicate item repeated across two pages or a skipped item that never appears at all, while a cursor tied to a specific item's identity is unaffected by insertions or deletions elsewhere in the list. Trigger the next page fetch using an IntersectionObserver watching a sentinel element near the end of the rendered list, not a scroll event listener — scroll events fire far more frequently than needed and require manual throttling to avoid janking the main thread, while IntersectionObserver is purpose-built for exactly this and fires only when the sentinel's visibility actually changes. Guard against firing a duplicate fetch for the same page: track whether a fetch for the next cursor is already in flight, in a ref or the fetch library's own status, and ignore any additional intersection trigger that fires while one is already pending — a fast scroll or a layout shift can trigger the observer's callback multiple times in quick succession before the first request even resolves. If the fetch library maintains its own cache and pagination state, such as TanStack Query's useInfiniteQuery, use its built-in mechanisms for tracking pages, next cursors, and in-flight status rather than duplicating that bookkeeping by hand alongside it. If scroll position must be restored — returning from a detail view back to the exact scroll position in the list — persist enough information to re-fetch or re-render every previously-loaded page up to that point, not just to set the numeric scrollTop value against a list that no longer has the same items loaded.
OUTPUT FORMAT
1. The pagination approach and fetch trigger, as real code.
2. The specific duplicate-fetch guard, shown explicitly, not left implicit in the fetch library's defaults.
3. Why cursor pagination was used, or an explicit note if the API only supports offset and what the accepted risk is as a result.
4. The scroll-restoration implementation, if required, or a note that it was not required and was left out.Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
The offset-versus-cursor distinction is not a stylistic preference, it is a provable correctness issue under a specific, common real-world condition: if even one item is inserted at the top of a feed between two page fetches, every offset-based page after that point shifts by one, which means the next fetch, still asking for "items 20 through 40," now returns an item already shown on the previous page and silently omits one further down the list — a bug that is invisible in a static demo with no new data arriving and appears specifically in production, on a live, frequently-updated feed, which is exactly the situation infinite scroll is usually built for in the first place. Requiring an explicit in-flight guard against duplicate fetches targets a real, common IntersectionObserver quirk: a fast scroll, a layout shift from an image loading, or even React re-rendering the sentinel element can cause the observer's callback to fire more than once in quick succession before the first triggered request has resolved, and without an explicit guard this produces either a duplicate page fetch or, worse, out-of-order pages appended in the wrong sequence if the second request happens to resolve before the first. Calling out useInfiniteQuery's built-in cursor and status tracking, rather than having the model reimplement the same bookkeeping by hand next to it, matters because a hand-rolled parallel tracking mechanism is exactly where these implementations tend to drift out of sync with the library's own internal state — two sources of truth for the same "is a fetch currently pending" question is a subtle, recurring source of the exact duplicate-fetch bug this whole prompt is built to prevent. The scroll-restoration requirement to re-render every previously-loaded page, rather than jump straight to a saved numeric scrollTop, matters because scrollTop is meaningless against a DOM that does not yet contain the same content it was measured against — restoring a raw pixel offset to a list that currently has only its first twenty items mounted places the viewport partway down content that has not been fetched yet, which either shows blank space or, on a list with variable-height items, lands the user at a visually wrong position entirely, whereas re-fetching or re-rendering the same pages first guarantees the DOM the scrollTop value was originally measured against actually exists again before the restoration happens.
What you get back
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['feed'], queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam }), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor, }); const sentinelRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); // isFetchingNextPage guard prevents a duplicate trigger mid-request } }); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); Cursor pagination used per the API's own nextCursor field — no offset risk here. Scroll restoration: persisted the full list of loaded page cursors in the router's navigation state, so returning to the feed re-renders every previously-loaded page before restoring scrollTop, rather than restoring a raw scroll number against a list that would otherwise only have its first page loaded.
Verified against
Claude Code Sonnet 4.6 · 2026-07-29
Cursor Cursor 2.1 · 2026-08-06
Changelog
- 2026-07-29 — Initial publish, verified against Claude Code (Sonnet 4.6) and Cursor 2.1 using TanStack Query 5.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
