Build an Infinite Scroll Feed
Tests performance thinking: pagination, virtualization, and state for very long lists.
- ~20 rowsin the DOM with 10,000 posts loaded
- 20 per pagetypical page size
- 600pxrootMargin to prefetch about a screen early
Show what to say
“I'd put an IntersectionObserver on a sentinel at the bottom that fetches the next page by cursor, one request at a time, with a Retry button on errors. Pages live in an infinite-query cache and rows are virtualized, so the DOM holds about 20 rows and Back returns to the same post instantly.”
Trade-offs
- Virtualization vs a plain list: constant DOM size, but it breaks Ctrl+F and needs measuring; use it past a few hundred items.
- Cursor vs offset pagination: stable as items arrive and fast deep pages, but no jumping to page 37.
- Infinite scroll vs Load more: seamless, but the footer is unreachable and keyboard users struggle.
Bring it up before they ask
A failed fetch must not freeze the feed: reset the in-flight flag in finally and show Retry, rather than looping against a struggling server.
A hidden marker at the bottom (a sentinel) asks for the next page by cursor, one request at a time. Only about 20 rows are ever on screen (virtualized), and Back lands on the same post.
Functional
- Load more posts near the bottom
- Loading, error with retry, and end-of-list states
- Restore the scroll position on Back
- A new posts banner at the top
Non-functional
- Smooth scrolling with thousands of items
- Bounded memory in long sessions
- Accessible to keyboard and screen-reader users
- Server-rendered first page for SEO and first paint
Out of scope
- Feed ranking and fan-out on the server
- Composing and editing posts
Clarifying questions
Read more · 58 words
- Order and page size? Newest first, or ranked. About 20 per page.
- Item height? Variable heights mean measuring each row.
- Session length? Thousands of posts means you need to limit what is on screen.
- Back and new posts? Back should land on the same post, with no refetch. New posts show as a banner.
- SEO? Server-render page one.
Answer with RADIO
Don't ask for "page 2". You read page 1, then 5 new posts arrive, and page 2 starts with 5 posts you already saw.
Read more · 99 words
Ask for "posts older than the last one I have", like a bookmark. That is a cursor. Offsets are fine for a small, static list where people jump to page 37. Structure a frontend answer as Requirements (done), Architecture, Data model, Interface and Optimizations.
Architecture
- FeedPage (server) fetches page one for first paint and crawlers.
- Feed (client) continues from
nextCursor, and renders virtualized rows, a loader row and a NewPostsBanner. - Post is an
articlewith its image box reserved bywidthandheight. - Data layer: a TanStack Query infinite query, or the
useInfiniteFeedhook.
Data model
// Query cache: one entry per page, in order
{ pages: [{ data: Post[], nextCursor: 'p_140' }, { data: Post[], nextCursor: null }] }
// Derived for rendering
items = pages.flatMap(p => p.data)
status: 'idle' | 'loading' | 'error' | 'done'
// Scroll anchor in sessionStorage: the first visible post and its offset
{ id: 'p_87', top: 120 }Interface
useInfiniteFeed(url, limit) returns { items, status, loadMore, sentinel }. The API lesson builds the cursor endpoint.
Read more · 30 words
Optimizations
- Prefetch:
rootMargin: '600px 0px'starts the next page a screen early. - No layout shift: reserve every image's box.
- Small DOM: virtualize, and drop old pages in very long sessions.
GET /api/feed?cursor=p_160&limit=20
→ 200 { "data": [ …20 posts ], "nextCursor": "p_140" } (nextCursor is null on the last page)
GET /api/feed/new-count?after=p_200
→ 200 { "count": 12 }Architecture diagram
Play Next page, Error and retry, End of list and Back navigation.
Now scroll the 10,000-row list with virtualization on and off. Watch the mounted-row count: that number keeps scrolling smooth.
Tap any box to see what it is and what it talks to.
- Client
- Service
- API gateway
- Cache
Text description
- You scroll. The 1 px sentinel after the last post comes within 600 px of the viewport (rootMargin), well before you reach the end.
- The observer calls loadMore(). Nothing is in flight, so it sets busy and starts one request.
- GET /api/feed?cursor=p_160&limit=20. The cursor is the id of the last post you have, not a page number.
- 20 posts and nextCursor p_140. WHERE id < cursor is an index range scan, as fast deep in the feed as at the top.
- The page is appended to the cache, the cursor moves to p_140, and finally resets busy.
- The list now holds 60 posts but mounts only the rows in view plus overscan: about 20 DOM rows.
- The new posts are in place before you get there. No spinner, no jump.
Step-by-step answer
Five moves: clarify (page size, ordering, item height, Back), choose the trigger (an IntersectionObserver on a sentinel, not scroll listeners), use cursors (they stay correct as new items arrive), control the DOM (virtualize), handle states (skeletons, retry, end of list, restored scroll, accessibility).
Read more · 300 words
Clarify
Then state the shape: a sentinel triggers cursor fetches, pages go in a , rows are virtualized.
Pick the trigger
Watch a 1 px sentinel after the last post. An IntersectionObserver costs nothing per frame. It reports only changes, so re-observe after each page in case a short page leaves the sentinel on screen.
Paginate with a cursor
With page numbers, 5 new posts at the top shift everything down 5, so the next page repeats 5. Deep offsets are slow too:
OFFSET 10000reads and drops 10,000 rows to return 20. A cursor reads 20.SELECT id, title, image, width, height FROM posts WHERE id < $1 -- the cursor: an index range scan, as fast on page 500 as on page 1 ORDER BY id DESC LIMIT 21; -- one extra row says whether another page existsGuard the fetch
One request in flight, reset in
finallyso one failure can't freeze the feed. On error, show Retry rather than looping against a struggling server.Virtualize
Render only the rows in view plus a few extra, in a container sized to the estimated total. 10,000 posts loaded, about 20 in the DOM.
Cache the pages
Back renders from memory with no request, and the saved anchor restores the same post.
Keep the layout stable
Reserve every image's box. Never insert posts above the reader: show "12 new posts ↑" instead.
Make it accessible
role="feed", with each post a focusablearticleusingaria-posinsetandaria-setsize(-1 while the total is unknown). Let keyboard users reach the footer, or offer a "Load more" button.
Implementation
Read more · 46 words
useInfiniteFeed.js:busyresets infinally; unmount aborts.Feed.jsx: states and semantics. Tests prove short pages keep loading and a 503 shows a working Retry.FeedVirtual.jsx: what you'd ship, with TanStack Query and Virtual.scroll-restore.js: saves the first visible post and its offset.
// useInfiniteFeed.js (fixes source bug #6: busy reset in finally, observer cleanup, abort)
import { useCallback, useEffect, useRef, useState } from 'react';
export function useInfiniteFeed(url, limit = 20) {
const [items, setItems] = useState([]);
const [status, setStatus] = useState('idle'); // idle | loading | error | done
const cursor = useRef(null), busy = useRef(false), ctrl = useRef(null);
const loadMore = useCallback(async () => {
if (busy.current || cursor.current === 'END') return; // one request in flight, nothing after the end
busy.current = true;
setStatus('loading');
ctrl.current = new AbortController();
try {
const qs = new URLSearchParams({ limit: String(limit), ...(cursor.current && { cursor: cursor.current }) });
const res = await fetch(`${url}?${qs}`, { signal: ctrl.current.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data, nextCursor } = await res.json();
setItems(prev => [...prev, ...data]);
cursor.current = nextCursor ?? 'END';
setStatus(nextCursor ? 'idle' : 'done');
} catch (e) {
if (e.name !== 'AbortError') setStatus('error'); // the UI offers Retry → loadMore()
} finally {
busy.current = false; // the source forgot this: one error froze the feed
}
}, [url, limit]);
const sentinel = useCallback(node => { // React 19: ref callbacks can return a cleanup
if (!node) return;
const io = new IntersectionObserver(([entry]) => entry.isIntersecting && loadMore(), { rootMargin: '600px 0px' });
io.observe(node);
return () => io.disconnect();
}, [loadMore]);
useEffect(() => () => ctrl.current?.abort(), []);
return { items, status, loadMore, sentinel };
}Failure modes
Read more · 63 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Fetch error | The feed stops | Reset busy in finally; a Retry button |
| Page shorter than the screen | Observer never fires again | Re-observe after each page |
| Fast scroll past the loader | Blank area | Extra rows, skeletons, bigger rootMargin |
| Images load late | Layout shift | Reserve the box |
| Very long session | Memory grows | Virtualize; maxPages |
| Unmount mid-request | Wasted work | Abort in cleanup |
Trade-offs
Read more · 45 words
| Choice | Gain | Cost |
|---|---|---|
| Virtualization, not a plain list | Constant DOM size | Breaks Ctrl+F; needs measuring |
| Infinite scroll, not "Load more" | Seamless | Footer unreachable |
| Cursor, not offset | Stable; fast deep pages | No jumping to page 37 |
Bigger rootMargin | Next page ready sooner | Fetches unseen data |
Follow-up questions
How do you show new posts at the top?
Poll a cheap count endpoint every 30 s and show a banner. A tap scrolls up and refetches page one. Never shift content above the reader unasked.
How do you stop memory growing in a long session?
Virtualize (about 20 rows), and cap cached pages with maxPages.
What about SEO?
Server-render the first page and add real links to older pages. Infinite scroll sits on top as an enhancement.
What if items are deleted while the user is paginating?
A cursor by id stays stable: a deleted post isn't returned and nothing shifts.
What about bi-directional scrolling, like chat history?
Add a top sentinel. When adding older items above, add the change in height to scrollTop, or the content jumps.
How would you test the feed?
Mock fetch and fake the IntersectionObserver. Assert one request in flight, the right cursor, and Retry with no automatic loop.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.