# Infinite Scroll & Feeds > Cursor-paginated feed with a bounded DOM and accessible fallback. Frontend architecture guidance from fearchitect, written by Abas Turabli and last reviewed 2026-06-21. Source: https://fearchitect.com/topics/infinite-scroll-feed Use it as reference for the task at hand. Before changing code, check this guidance against the codebase: where the code already makes a different, deliberate choice, flag the conflict instead of rewriting it. Library APIs move faster than this guide, so confirm exact signatures in the official docs linked at the end. ## Summary A production feed uses keyset/cursor pagination — not offset — so skips and duplicates don't occur as data shifts. An `IntersectionObserver` sentinel triggers the next page load automatically. TanStack Virtual keeps the rendered DOM bounded regardless of list length. A 'load more' button preserves keyboard and screen-reader access. ## Definition An infinite-scroll feed auto-fetches the next page as the user nears the bottom, creating the appearance of a continuous list. The two foundational choices drive every other decision: - **Cursor vs offset pagination.** Offset pagination (`LIMIT n OFFSET k`) produces skips and duplicates when rows are inserted while the user scrolls. A keyset cursor (e.g. `after: "2xZ9q"`) encodes the last item's sort key; inserts don't shift it. - **Observer vs scroll event.** A scroll-event listener fires hundreds of times per scroll and lives on the main thread. An `IntersectionObserver` sentinel fires once, off the critical path, when an invisible marker enters the viewport. ## Diagram ```mermaid sequenceDiagram participant V as Viewport participant S as Sentinel participant IO as IntersectionObserver participant H as useInfiniteQuery participant API as API V->>S: sentinel enters viewport S->>IO: threshold crossed IO->>H: callback fires → fetchNextPage() H->>API: GET /feed?after=cursor API-->>H: {items, nextCursor} H-->>V: appends page to list ``` Sentinel visibility triggers a single observer callback; no scroll listener needed. ## IntersectionObserver sentinel hook The hook attaches an observer to a ref, calls `onIntersect` once when visible, and disconnects on unmount. Wire the ref to an empty `
` after the list. **useIntersectionSentinel — observer hook** ```tsx import { useEffect, useRef } from "react"; interface Options { onIntersect: () => void; enabled?: boolean; rootMargin?: string; } /** Attach to a sentinel
at the bottom of the list. */ export function useIntersectionSentinel({ onIntersect, enabled = true, rootMargin = "200px", }: Options) { const ref = useRef(null); useEffect(() => { if (!enabled || !ref.current) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) onIntersect(); }, { rootMargin }, ); observer.observe(ref.current); return () => observer.disconnect(); }, [enabled, onIntersect, rootMargin]); return ref; } // --- Usage with TanStack Query v5 infinite query --- import { useInfiniteQuery } from "@tanstack/react-query"; interface Post { id: string; body: string; } interface Page { items: Post[]; nextCursor: string | null; } async function fetchFeed({ pageParam = null }: { pageParam?: string | null }): Promise { const url = pageParam ? `/api/feed?after=${pageParam}` : "/api/feed"; const res = await fetch(url); if (!res.ok) throw new Error("fetch failed"); return res.json(); } export function Feed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["feed"], queryFn: fetchFeed, initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, }); const sentinelRef = useIntersectionSentinel({ onIntersect: fetchNextPage, enabled: hasNextPage && !isFetchingNextPage, }); const posts = data?.pages.flatMap((p) => p.items) ?? []; return (
    {posts.map((post) => (
  • {post.body}
  • ))}
{/* Sentinel — invisible; triggers next fetch */} ); } ``` `rootMargin: '200px'` pre-fetches before the sentinel is fully visible. The button fallback ensures keyboard and screen-reader users can advance the feed. ## Watch out: Virtualize once the list exceeds ~200 rows Without windowing, every page appended keeps its DOM nodes alive. At 1,000+ items this causes layout thrash and high memory. Add `@tanstack/react-virtual` — measure each row, render only the visible window (~30 nodes), and set a spacer div equal to the total estimated height. Scroll restoration: save `scrollTop` in session storage on `popstate` and restore after the initial page hydrates. ## Key terms - **keyset pagination**: Paginate by encoding the last-seen row's sort key as a cursor; immune to inserts shifting offsets. - **sentinel element**: An empty DOM node at the list bottom; `IntersectionObserver` fires when it enters the viewport. - **list virtualization**: Render only visible rows plus a small overscan; TanStack Virtual manages row measurement and offsets. - **scroll restoration**: Saving and re-applying scroll position so back-navigation returns users to their place in the feed. - **IntersectionObserver**: Browser API that fires a callback when a target element crosses a viewport threshold; no scroll listener needed. ## Related topics - [Server State & Data Fetching](https://fearchitect.com/topics/server-state-data-fetching.md): Async, shared, remote-owned data that requires a dedicated cache layer. - [Render Performance](https://fearchitect.com/topics/render-performance-patterns.md): Skip renders, defer slow work, and virtualize long lists. - [Data Table / Data Grid](https://fearchitect.com/topics/data-table.md): Headless table logic separate from rendering, with server-side ops for large datasets. - [Accessibility (a11y)](https://fearchitect.com/topics/accessibility.md): WCAG 2.2 AA: semantic HTML, keyboard nav, and ARIA done right. - [Modal & Dialog System](https://fearchitect.com/topics/modal-dialog-system.md): Native vs portal pattern: focus trap, top-layer, a11y. - [RADIO: Frontend Interview Framework](https://fearchitect.com/topics/radio-interview-framework.md): Requirements → Architecture → Data → Interface → Optimizations: a design-round scaffold. ## Further reading - [TanStack Query — Infinite Queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) - [TanStack Virtual — getting started](https://tanstack.com/virtual/latest/docs/introduction) - [MDN — Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) - [WCAG 2.2 — 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)