FrontendMedium30 min

Build an Infinite Scroll Feed

Tests performance thinking: pagination, virtualization, and state for very long lists.

Say it in 30 seconds
  • ~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.

0 of 0 sections ticked
30-second answer

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 article with its image box reserved by width and height.
  • Data layer: a TanStack Query infinite query, or the useInfiniteFeed hook.
A database index is a book's edge tabs: jump straight to the section instead of reading every page.

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.

Press Play to watch it run · 7 steps

An IntersectionObserver on a sentinel near the end of the list triggers one cursor-paginated fetch. The page is appended to the query cache, and the virtualized list mounts only the rows near the viewport.

Infinite feed: from scroll to new rowsAn IntersectionObserver on a sentinel near the end of the list triggers one cursor-paginated fetch. The page is appended to the query cache, and the virtualized list mounts only the rows near the viewport.scrollloadMore()GET /api/feedappend pageitemsnew rowsUser scrollsIntersectionObserver with rootMargin 600px; Retry calls the same loadMore().Sentinel visibleor Retry tappedA busy flag reset in finally, plus an AbortController for unmount.Fetch next page1 in flightReturns { data, nextCursor }; nextCursor is null at the end.Feed API?cursor=&limit=20pages: [{ data, nextCursor }], kept across navigations.Page cacheinfinite queryOnly visible rows plus overscan are mounted, positioned with transforms.Virtualized list~20 rows in DOM

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • API gateway
  • Cache
Open in playground
Text description
  1. You scroll. The 1 px sentinel after the last post comes within 600 px of the viewport (rootMargin), well before you reach the end.
  2. The observer calls loadMore(). Nothing is in flight, so it sets busy and starts one request.
  3. GET /api/feed?cursor=p_160&limit=20. The cursor is the id of the last post you have, not a page number.
  4. 20 posts and nextCursor p_140. WHERE id < cursor is an index range scan, as fast deep in the feed as at the top.
  5. The page is appended to the cache, the cursor moves to p_140, and finally resets busy.
  6. The list now holds 60 posts but mounts only the rows in view plus overscan: about 20 DOM rows.
  7. The new posts are in place before you get there. No spinner, no jump.
Interactive

Virtualized list

Scroll a feed of thousands of rows. With windowing only the rows in view (plus a few spare) exist in the DOM; the rest is an empty spacer.

Ada shipped a cache layer#1
Linus tuned a connection pool#2
Grace benchmarked Redis#3
Ken asked about queues#4
Barbara drew an architecture#5
Edsger posted a load test#6
Margaret fixed a race condition#7
Tim wrote about sharding#8
Radia shipped a cache layer#9

In view: rows 1–6 · mounted window: rows 1–9 (3 spare above and below) · spacer height 440,000 px

Virtualization on: about 13 of 10,000 rows are in the DOM (0.13%). Scrolling only swaps which rows are mounted, so the cost stays flat at any list length.

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
  1. Clarify

    Then state the shape: a sentinel triggers cursor fetches, pages go in a , rows are virtualized.

  2. 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.

  3. 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 10000 reads 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 exists
  4. Guard the fetch

    One request in flight, reset in finally so one failure can't freeze the feed. On error, show Retry rather than looping against a struggling server.

  5. 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.

  6. Cache the pages

    Back renders from memory with no request, and the saved anchor restores the same post.

  7. Keep the layout stable

    Reserve every image's box. Never insert posts above the reader: show "12 new posts ↑" instead.

  8. Make it accessible

    role="feed", with each post a focusable article using aria-posinset and aria-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: busy resets in finally; 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 breaksEffectMitigation
Fetch errorThe feed stopsReset busy in finally; a Retry button
Page shorter than the screenObserver never fires againRe-observe after each page
Fast scroll past the loaderBlank areaExtra rows, skeletons, bigger rootMargin
Images load lateLayout shiftReserve the box
Very long sessionMemory growsVirtualize; maxPages
Unmount mid-requestWasted workAbort in cleanup

Trade-offs

Read more · 45 words
ChoiceGainCost
Virtualization, not a plain listConstant DOM sizeBreaks Ctrl+F; needs measuring
Infinite scroll, not "Load more"SeamlessFooter unreachable
Cursor, not offsetStable; fast deep pagesNo jumping to page 37
Bigger rootMarginNext page ready soonerFetches 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 3

Score 2 out of 3 or better to mark this page complete.

1. Why paginate the feed with a cursor instead of page numbers?