How to Design an Infinite Scroll Feed: The Complete System Design Guide

Full Stack

·

April 3, 2026

Infinite scroll feed system design architecture showing virtualized rendering and data flow patterns
Intro

Learn how to architect an infinite scroll feed like LinkedIn, Twitter, and Instagram from scratch. This guide covers virtualized lists, Intersection Observer API, cursor-based pagination, skeleton loading, memory management, and the complete architecture used by top social media platforms.

Quick Answer

Infinite scroll renders only visible items, loads more content on demand, uses cursor-based pagination for stability, shows skeleton screens for faster feel, and manages memory efficiently to avoid slowdowns during long scrolling sessions.

You scroll through LinkedIn every day. Posts load seamlessly as your thumb moves. No "Next Page" button. No loading screen. Just an endless, buttery-smooth stream of content.

But have you ever stopped and asked yourself: how does this actually work?

Behind that simple scrolling experience is a surprisingly complex system. Virtualized rendering, intersection observers, cursor-based pagination, memory management, skeleton loading states. Most developers have never thought about any of it.

Until an interviewer at Meta asks them to design it from scratch.

This is one of the most frequently asked frontend system design interview questions at top companies. And in this guide, you'll learn exactly how to architect an infinite scroll feed, the same patterns used by LinkedIn, Twitter, Instagram, and TikTok.

Infinite scroll feed system design showing multiple social media posts loading seamlessly in a browser viewport
Behind every infinite scroll is a complex system most developers never think about

What Exactly Are We Designing?

Before writing any code, let's define the requirements. This is exactly how you'd start in a real interview.

An infinite scroll feed needs to:

  • Load an initial batch of posts on page load
  • Automatically fetch and append more posts as the user scrolls down
  • Handle thousands of posts without crashing the browser
  • Maintain smooth 60fps scrolling performance at all times
  • Show meaningful loading states while new data is being fetched
  • Work reliably even when new posts are being published in real time
  • Manage memory so the browser doesn't slow down after extended scrolling

Sounds simple when you use it. Incredibly nuanced when you build it.

The key insight that separates junior developers from senior ones is this: an infinite scroll feed is not a list. It's a system. And designing that system requires making trade-off decisions across rendering, data fetching, state management, and performance.

The Naive Approach (and Why It Fails)

Let's start with what most developers would do if asked to build this quickly.

Fetch all the posts from the API. Store them in state. Map over the array and render a <PostCard /> for each one. Add a scroll listener that fetches more posts when the user reaches the bottom.

It works. For about 50 posts.

Here's what happens after that:

The DOM explodes. Every post is a real DOM node with images, text, buttons, and event listeners. At 500 posts, you have thousands of DOM nodes. At 2,000 posts, the browser is managing tens of thousands of elements. Scroll performance drops. Layout recalculations become expensive. The page starts to stutter.

Memory climbs and never comes back. Every image that scrolled past is still loaded in memory. Every event listener is still attached. Every component is still mounted. The garbage collector can't clean up anything because everything is still referenced in the DOM tree.

Scroll event listeners are expensive. Firing a function on every scroll pixel means potentially hundreds of executions per second. Even with throttling and debouncing, you're fighting against the browser's rendering pipeline.

This is the "it works on my MacBook Pro with 10 posts" approach. It collapses in production with real data at real scale.

Comparison showing naive approach with thousands of DOM nodes versus optimized virtualized approach with only visible nodes rendered
10,000 posts in the data. Only 15 in the DOM. That's the trick.

Virtualized Lists: The Core Technique

This is the single most important concept in infinite scroll design: only render what the user can actually see.

If the viewport shows 10 posts at a time, there's absolutely no reason to have 5,000 post components mounted in the DOM. Virtualization (also called windowing) solves this by maintaining a small "window" of rendered items that slides along as the user scrolls.

Here's how it works:

The virtualized list knows the total number of items and the height of each item (or estimates it). Based on the current scroll position, it calculates which items are currently visible in the viewport. It renders only those items, plus a small buffer above and below (usually 3 to 5 extra items in each direction to prevent flashing during fast scrolls). As the user scrolls, items leaving the viewport are unmounted and new items entering are mounted.

The result: your data might contain 10,000 posts, but the DOM only ever has 15 to 20 elements at any given time. Scroll performance stays constant regardless of how much data you've loaded.

JavaScript
// Conceptual virtualization logic
function VirtualizedFeed({ posts, itemHeight, viewportHeight }) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(viewportHeight / itemHeight) + 1,
    posts.length
  );

  const visiblePosts = posts.slice(startIndex, endIndex);
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: posts.length * itemHeight, position: "relative" }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ transform: `translateY(${offsetY}px)` }}>
        {visiblePosts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

In production, you wouldn't write this from scratch. Libraries like TanStack Virtual (formerly react-virtual), react-window, and react-virtuoso handle the complex edge cases: variable height items, smooth scrolling, scroll restoration, and dynamic content that changes size after rendering.

The key trade-off to discuss in an interview: virtualization adds complexity. You lose native browser search (Ctrl+F won't find off-screen items). Scroll restoration after navigation becomes harder. Accessibility needs extra attention because screen readers might not see unmounted content. These trade-offs are worth it at scale, but you should be able to articulate when virtualization is overkill (hint: if your list will never exceed 100 items, you probably don't need it).

Intersection Observer: Detecting When to Load More

Virtualization handles the rendering problem. But how do you know when to fetch the next batch of posts?

The old approach was attaching a scroll event listener and checking whether the user has scrolled near the bottom of the container. This works but it's expensive. Scroll events fire dozens of times per second, and even with throttling, you're running calculations on every tick of the scroll.

The modern approach uses the Intersection Observer API, a browser-native feature designed specifically for detecting when elements enter or leave the viewport.

Here's the pattern:

Place an invisible sentinel element (a small empty <div>) at the bottom of your rendered list. Create an Intersection Observer that watches this sentinel. When the sentinel scrolls into view, it means the user is approaching the end of the current content. The observer triggers a callback, and you fetch the next page of data.

JavaScript
function useInfiniteScroll(callback) {
  const sentinelRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          callback();
        }
      },
      { rootMargin: "200px" }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [callback]);

  return sentinelRef;
}

// Usage
function Feed() {
  const { data, fetchNextPage } = useInfiniteQuery(/* ... */);
  const sentinelRef = useInfiniteScroll(fetchNextPage);

  return (
    <VirtualizedList>
      {data.pages.flat().map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      <div ref={sentinelRef} />
    </VirtualizedList>
  );
}

The rootMargin: "200px" is a critical detail. It tells the observer to trigger 200 pixels before the sentinel actually enters the viewport. This gives your API call a head start, so by the time the user scrolls to the bottom, the new posts are already loaded and ready. The user never sees a loading state during normal-speed scrolling. It feels like the content is infinite and always there.

Why Intersection Observer wins over scroll listeners:

  • Performance: The browser optimizes observation internally. No JavaScript runs on every scroll pixel.
  • Simplicity: No manual scroll position math, no throttle/debounce boilerplate.
  • Reliability: Works with any scrollable container, not just the window.
  • Battery life: Less CPU usage means less drain on mobile devices.

Cursor-Based Pagination: Why Offset Pagination Breaks

Your feed needs to fetch data in pages. The question is: how do you tell the API which page you want next?

Most developers reach for offset pagination first because it's familiar:

GET /api/posts?page=2&limit=20

This says "skip the first 20 posts, give me the next 20." Simple. But it has a critical flaw in real-time feeds.

Imagine this scenario: the user has loaded page 1 (posts 1 through 20). While they're reading, 3 new posts are published at the top of the feed. When they scroll down and the app requests page 2 (skip 20, take 20), the server counts from the new top. Posts 18, 19, and 20 from the original set now fall into position 21, 22, and 23. The user sees posts 21 through 40 from the new ordering, which means they see duplicates of posts 18 through 20 and never see posts that shifted position.

Cursor-based pagination solves this completely:

GET /api/posts?after=post_abc123&limit=20

Instead of saying "skip N items," you say "give me 20 items that come after this specific post." It doesn't matter how many new posts were added above. The cursor points to a fixed reference point in the dataset. The response is always stable and predictable.

JavaScript
// React Query infinite query with cursor pagination
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam }) =>
    fetch(`/api/posts?after=${pageParam}&limit=20`).then((r) => r.json()),
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  initialPageParam: "",
});

Every major social media platform uses cursor-based pagination: LinkedIn, Twitter/X, Instagram, Facebook, TikTok. There's a reason offset pagination doesn't exist in any of their public APIs. If your interview answer uses offset pagination for a feed, that's a red flag for the interviewer.

The trade-off: cursor-based pagination doesn't support "jump to page 5." You can only go forward (and sometimes backward). For a social feed, this is perfectly fine because nobody navigates a feed by page number. For something like an admin dashboard with paginated tables, offset pagination might still be the better choice.

Diagram comparing offset pagination versus cursor-based pagination showing how offset breaks when new items are added to a real-time feed
Offset pagination breaks in real-time feeds. Cursor pagination stays stable.

Skeleton Loading: Designing the Waiting Experience

What should the user see while the next batch of posts is loading?

There are three common approaches, and only one of them is correct for a feed:

Spinner: A loading indicator at the bottom. The user sees it, knows something is loading, and waits. It works, but it breaks the flow. The endless scroll suddenly feels like it has an end. Studies show spinners make wait times feel longer than they actually are.

Blank space: Nothing appears until the data arrives. This is the worst option. The user thinks the feed is broken or has ended. They might navigate away before the content loads.

Skeleton screens: Gray placeholder blocks that mirror the exact shape and layout of the real post cards. The user sees the "structure" of upcoming content immediately, and the real data fills in smoothly once loaded. Research from Google and Facebook shows skeleton screens reduce perceived load time by up to 30% compared to spinners.

JavaScript
function PostSkeleton() {
  return (
    <div className="post-skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-content">
        <div className="skeleton-line skeleton-name" />
        <div className="skeleton-line skeleton-title" />
        <div className="skeleton-line skeleton-text-long" />
        <div className="skeleton-line skeleton-text-medium" />
        <div className="skeleton-line skeleton-text-short" />
      </div>
    </div>
  );
}

function Feed() {
  const { data, isFetchingNextPage } = useInfiniteQuery(/* ... */);

  return (
    <VirtualizedList>
      {data.pages.flat().map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      {isFetchingNextPage && (
        <>
          <PostSkeleton />
          <PostSkeleton />
          <PostSkeleton />
        </>
      )}
    </VirtualizedList>
  );
}

The skeleton should match the real content as closely as possible. If your post card has a circular avatar on the left, a name and title on the right, and 3 lines of text below, your skeleton should have a gray circle on the left, two short gray bars on the right, and 3 gray bars of decreasing width below. The closer the match, the smoother the transition feels.

A subtle animation (a shimmer or pulse effect on the gray blocks) signals that content is actively loading, not frozen. This is a small detail that significantly improves perceived quality.

Skeleton loading screen comparison showing placeholder blocks that mirror the layout of real social media post cards
Skeletons reduce perceived load time by up to 30% compared to spinners

Memory Management: The Hidden Performance Killer

Here's a problem most developers don't think about until production users start complaining: memory leaks in long-lived scroll sessions.

A user opens LinkedIn and scrolls for 20 minutes. They've loaded 800 posts. Every single image they scrolled past is still cached in the browser's memory. Every post card component that was mounted and then unmounted by the virtualizer might still have dangling references. Event listeners attached to DOM nodes that no longer exist can prevent garbage collection.

The browser's memory usage climbs from 100MB to 400MB to 800MB. On mobile devices, the OS starts killing background tabs. On low-end Android devices, the app becomes unusable.

Here's how to fight it:

Virtualization does the heavy lifting. Because off-screen components are unmounted, their React state, DOM nodes, and most associated memory are freed automatically. This is the primary defense.

Image cleanup matters. If you're creating object URLs for images with URL.createObjectURL(), you need to revoke them when the component unmounts using URL.revokeObjectURL(). Without this, every image blob stays in memory forever.

Limit your page cache. If you're using React Query's useInfiniteQuery, you can configure maxPages to limit how many pages of data are kept in the cache. When the user has scrolled through 50 pages, you don't need pages 1 through 40 sitting in memory.

JavaScript
const { data, fetchNextPage } = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: fetchFeedPage,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  maxPages: 10,
  initialPageParam: "",
});

Clean up side effects. Any useEffect that creates subscriptions, timers, or event listeners must return a cleanup function. This isn't specific to infinite scroll, but scroll-heavy pages amplify the consequences of missing cleanups because components mount and unmount hundreds of times during a single session.

Monitor in production. Use the browser's Performance Monitor (Chrome DevTools > More Tools > Performance Monitor) to watch JS Heap Size over time. If it trends upward without plateauing, you have a memory leak. The performance.measureUserAgentSpecificMemory() API can help track this in production with real user data.

The Complete Architecture

Let's bring everything together into a single, coherent system. This is the "architecture diagram" slide of your interview answer.

Scroll feed architecture diagram
Scroll feed architecture diagram


The data flow:

  1. The Feed Container initializes useInfiniteQuery and fetches the first page of posts.
  2. Posts are passed to the Virtualized List, which calculates which items are in the viewport and renders only those Post Cards.
  3. A sentinel <div> sits at the bottom of the rendered list.
  4. The Intersection Observer watches the sentinel. When it enters the viewport (with a 200px head start), it calls fetchNextPage().
  5. The API call uses cursor-based pagination, sending the last post's ID as the cursor.
  6. While the fetch is in progress, skeleton loaders appear below the existing posts.
  7. When data arrives, new posts are appended. The virtualizer recalculates and renders the newly visible items.
  8. The sentinel moves to the new bottom, and the cycle repeats.
  9. Memory is managed through virtualization (automatic unmounting), image cleanup (revoking object URLs), and page cache limits (maxPages in React Query).

This is the architecture running behind every social media feed you use. The specifics vary (Twitter uses a custom virtualizer, Instagram prioritizes image preloading, TikTok focuses on video buffer management), but the fundamental patterns are identical.

Edge Cases That Interviewers Love to Ask About

If you nail the core architecture, a strong interviewer will push you on edge cases. Here are the ones that come up most often:

What if the user scrolls back to the top? With virtualization, the items at the top are unmounted. When the user scrolls back, they need to be re-rendered. If you've limited your page cache with maxPages, the data might need to be re-fetched. Scroll restoration (maintaining the exact scroll position) is critical here. TanStack Virtual handles this well out of the box.

What about new posts published while the user is reading? You have two options. Show a "New posts available" banner at the top (like Twitter) that the user can tap to refresh. Or silently prepend new posts above the current scroll position without shifting the user's viewport. The second approach is technically harder but provides a better experience.

How do you handle post deletions? If a post the user is currently viewing gets deleted, you need to gracefully remove it from the list without causing a jarring layout shift. Animate the removal, let the items below slide up smoothly, and update your cursor references if needed.

What about variable height posts? Some posts are short text. Some have large images. Some have embedded videos. This breaks simple virtualization that assumes fixed heights. Dynamic measurement is the solution: render the item, measure its actual height, cache the measurement, and use it for future scroll position calculations. Libraries like react-virtuoso handle this natively.

How does search work? Native browser search (Ctrl+F) can't find text in unmounted components. If search is a requirement, you need to implement custom search functionality that queries your data directly rather than relying on DOM search.

Performance Benchmarks to Mention in Interviews

Knowing the numbers makes your interview answer significantly more credible:

  • A well-implemented virtualized feed should maintain 60fps scrolling regardless of dataset size
  • Initial page load with 20 posts should achieve a Largest Contentful Paint (LCP) under 2.5 seconds
  • Memory usage should plateau (not continuously climb) after the initial load, staying under 150MB even after extended scrolling sessions
  • Time to interactive after appending a new page of posts should be under 50ms
  • The sentinel trigger with rootMargin should create a seamless experience where the user never sees a loading state during normal-speed scrolling

When Not to Use Infinite Scroll

Infinite scroll is not always the right choice. It's important to know when to recommend alternatives:

Content that users need to reference later. If users need to find a specific item they saw earlier, infinite scroll makes this nearly impossible. A paginated approach with URLs for each page is better.

Small datasets. If you'll never have more than 50 items, the complexity of virtualization, intersection observers, and cursor pagination is overkill. Just render the list.

E-commerce product listings. Users often want to compare items, share specific result pages, and use browser back/forward. Traditional pagination with URL-based page params is usually better here. Etsy and Amazon both use pagination, not infinite scroll, for search results.

Accessibility-critical applications. Infinite scroll can be challenging for screen readers and keyboard-only users. If accessibility is a primary concern, consider a "Load More" button instead of automatic infinite scroll. It provides the same data-loading pattern but gives users explicit control.

Key Takeaways

Designing an infinite scroll feed tests your ability to think in systems, not just components. Here's what to remember:

  • Virtualize the list. Never render thousands of DOM nodes. Only mount what's visible in the viewport, plus a small buffer.
  • Use Intersection Observer. Ditch scroll event listeners. Let the browser handle viewport detection natively and efficiently.
  • Choose cursor-based pagination. Offset pagination breaks in real-time feeds. Cursors provide stable, predictable page boundaries.
  • Design the loading state. Skeleton screens that match your content layout reduce perceived load time by up to 30%.
  • Manage memory actively. Virtualization, image cleanup, page cache limits, and proper effect cleanup prevent memory leaks in long sessions.
  • Know the edge cases. Scroll restoration, new post injection, variable heights, and search are the follow-up questions that separate good answers from great ones.

The next time you're scrolling through LinkedIn and posts appear magically as you move your thumb, you'll know exactly what's happening behind the scenes. More importantly, you'll be able to explain it in an interview.

And that's the whole point.